Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion agentic/db-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ npm install @agentic-kit/db-tools

- **18 typed db tools** — `provision_database`, `provision_blueprint`, `describe_schema`, `add_relation`, `delete_table`, `create_field` / `update_field` / `delete_field`, `add_policies`, `add_records`, `manage_entity_types`, `create_api_key`, `run_codegen`, and the template suite (`list` / `create` / `apply` / `update` / `delete`). `constructiveDbTools` is the list, in registration order.
- **Host injection** — credentials, backend endpoints and data-plane tokens come from the host application, not from the package. Call `configureHost()` once at startup; the tools read it lazily per call.
- **Project context** — `resolveProjectContext` / `resolveDataToken` resolve the database a tool acts on from the cwd's `.env` plus the host's session, and `deriveSubdomainEndpoint` derives its per-database endpoints.
- **Project context** — `resolveProjectContext` / `resolveDataToken` resolve the database a tool acts on from the cwd's `.env` plus the host's session, and `deriveSubdomainEndpoint` derives its per-database endpoints. `probeDatabase` says whether that database is still there, and `readSchemaModel` reads its app tables.
- **Provisioning model** — the pinned `node-type-registry` presets, the provision manifest, and overlay resolution (`resolveProvisionModules`).
- **`constructiveGateDeps`** — the confirm gate's host capabilities (is the project runnable, is there a data token, what tables would a template copy) answered by these resolvers, so every adapter gates the same tools against the same project state instead of restating it.
- **`toolSchema`** — a tool's zod parameters as plain JSON Schema, for adapters whose harness wants JSON Schema rather than zod.
Expand All @@ -56,6 +56,45 @@ A tool executes against nothing but its params and a context, so it is callable
const result = await describeSchemaTool.execute({ database_name: 'my-app' }, { cwd: process.cwd() });
```

## Is the bound database still there?

`probeDatabase` (or `probeBoundDatabase(id)`, which uses the host's account and control endpoint) asks the platform api and returns one of:

| `status` | Means | Recovery |
| --- | --- | --- |
| `found` | Live; carries `name`, `ownerId` and `hash` (the schema hash, which changes on every schema change) | none |
| `suspended` | It exists and keeps its data, but is held (`suspendedAt`, `suspendedReason`) | wait for the hold to lift; never replace it |
| `missing` | The backend answered and this account can't see it: deleted, or access lost | re-provision |
| `unreachable` | No usable answer; `cause` is `network`, `server`, `auth` (account credential rejected) or `error` (an error the probe doesn't know) | retry or sign in; never re-provision |

Only `missing` means the database is gone. On a backend without `hash`, the probe retries once without it and `hash` is undefined. `resolveProjectContext` reports the same states as `db-missing`, `db-suspended`, `account-mismatch` and `backend-unreachable` (with `cause`), and a resolved context carries `hash`.

Tenant APIs answer `ACCESS_SUSPENDED` (a 403 with a JSON body) for a deleted or suspended database. The SDK's `FetchAdapter` drops that body, so `postGraphQL` and `statusAdapter` (a drop-in ORM adapter) keep each error's `extensions.code` and HTTP status, and `classifyBackendErrors` sorts them into `suspended`, `network`, `server`, `auth`, `forbidden` or `graphql`. `add_records`, `run_codegen` and `create_api_key` return `details.code: 'database-unavailable'` on `ACCESS_SUSPENDED`, and `run_codegen` doesn't retry a refused introspection.

## The live schema

`readSchemaModel(context)` reads the app tables (category `APP`, whoever created them) from the metaschema in one piece, or throws `SchemaReadError`; a partial read would look like columns dropped outside. Each table has its stable metaschema id, every field with its category (a status column made by `DataStatusField` is `MODULE`), a normalised type, `isRequired`, a structured default (`value`, `now`, `today`, `generated` or `expression`), the values an IN-list check allows, primary-key and unique flags, foreign keys with their delete and update actions (targets outside the app tables still resolve), and its policies. `accessOf(policies)` maps them to `shared`, `private` or `custom`. The model's `hash` is read before anything else.

## Provisioning

`provision_database` and the host-side `provisionDatabase(input, { cwd })` never create a second database by accident. Calls for one project run one at a time. A binding is `DATABASE_ID` alone: it is probed, then kept (healing the token, endpoints and any missing keys) or refused. Replace mode (`mode: 'replace', replaces: <the id the caller saw>`) swaps the binding only if it is still that one, and never swaps a suspended database, or a live one on the host path. A provision that outlasts the poll is recorded in `.env` and resumed by the next call. A ticket that can't be read back, or a call that lost track of its ticket, finds an in-flight provision of the same name with `listOwn` before anything is requested; a completed one is only reported. After `MAX_PENDING_ATTEMPTS` waits the call fails `provision-stuck` until the host passes `abandonPending`.

Failures carry a `code`: `signed-out`, `backend-unreachable`, `db-missing`, `db-suspended`, `account-mismatch`, `binding-live`, `binding-changed`, `pending-other`, `ticket-lost`, `provision-stuck`, `name-taken` (with `heldBy` when a database of this account holds the name, which the host can bind with `adopt: <id>`), `binding-not-saved`, `env-write-failed`, `reprovision-not-allowed` or `failed`.

Host hooks for this, all optional:

- `persistBinding(binding)` saves the binding in the host's own store before `.env` is written. If it throws, `.env` is left alone, the ticket stays recorded, and the call fails `binding-not-saved`.
- `toolReprovision: false` makes the tool's `reprovision: true` fail `reprovision-not-allowed`, for a host that replaces databases only after the user approves.
- `reprovisionHint` replaces the "re-provision with `reprovision: true`" advice in failure messages.

## Blueprint builds

`constructBlueprint` queues the build and returns the construction id, and the backend builds one blueprint at a time. So `provision_blueprint`, `add_relation` and `apply_template` wait for their build through `buildBlueprint` (or `waitForConstruction`, given an id), which reads the construction every 2 s until it is `constructed`, and returns the construction id and its table map, or `failed`, and throws with the backend's error code and message. The error is read in a separate query after the failure, so the status reads also work on a backend without the `error` column, and there the failure says the backend gave no reason. A host that polls on its own reads the reason the same way with `readConstructionFailure`. On a backend that still builds inside the mutation, the first read already says `constructed`.

The wait gives up after `CONSTRUCTION_DEADLINE_MS` (240 s: the build's own 120 s statement timeout, plus as long again in the queue), when the signal aborts, or when the status can't be read (a GraphQL error, or 3 network failures in a row), and says the build may still finish. A host that limits how long a tool runs should give these three tools more than 240 s.

Once the backend has queued the build, `buildBlueprint` throws every failure as a `BlueprintBuildError` with its `constructionId`, and `provision_blueprint` puts that id on `details.constructionId`, whether the build succeeded or not. The build carries on without the call, so a host that retries failed calls shouldn't retry one that has an id: a second build of the same tables fails on the ones the first made. Without an id, nothing was queued.

## Related

| Package | Role |
Expand Down
189 changes: 189 additions & 0 deletions agentic/db-tools/__tests__/blueprint-construction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import type { HarnessToolContext } from '@agentic-kit/harness';

jest.mock('../src/context', () => ({ resolveProjectContext: jest.fn() }));

import { modules } from '@constructive-io/sdk';

import { buildBlueprint, waitForConstruction } from '../src/blueprint-construction';
import { resolveProjectContext } from '../src/context';
import { provisionBlueprintTool } from '../src/tools/provision-blueprint';

const mockResolve = resolveProjectContext as jest.MockedFunction<typeof resolveProjectContext>;

type Row = { status: string; tableMap?: Record<string, string> };

// A schema from before the queued build has no `error` column, and rejects any
// query that names it.
const NO_ERROR_COLUMN = Symbol('no error column');
// A status read the backend refuses.
const REFUSED = Symbol('refused');
// A construct request that never gets an answer.
const DROPPED = Symbol('dropped');

type Backend = {
statuses: ReadonlyArray<Row | typeof REFUSED>;
error?: unknown;
construct?: typeof DROPPED;
onRead?: () => void;
};

const ok = (data: unknown): modules.QueryResult<unknown> => ({ ok: true, data, errors: undefined });

// The modules backend behind the real ORM client, so the test sees the
// documents the SDK builds. Each status read returns the next of `statuses`,
// and the last one repeats.
function backend({ statuses, error = null, construct, onRead }: Backend) {
const documents: string[] = [];
let reads = 0;
const answer = (document: string): modules.QueryResult<unknown> => {
documents.push(document);
if (document.includes('constructBlueprint')) {
if (construct === DROPPED) throw new TypeError('fetch failed');
return ok({ constructBlueprint: { result: 'con-1' } });
}
if (document.includes('createBlueprint')) {
return ok({ createBlueprint: { blueprint: { id: 'bp-1', name: 'library' } } });
}
if (/\berror\b/.test(document)) {
if (error === NO_ERROR_COLUMN) {
const message = 'Cannot query field "error" on type "BlueprintConstruction".';
return { ok: false, data: null, errors: [{ message }] };
}
return ok({ blueprintConstructions: { nodes: [{ error }] } });
}
onRead?.();
const row = statuses[Math.min(reads++, statuses.length - 1)];
if (row === REFUSED) {
return { ok: false, data: null, errors: [{ message: 'permission denied for table blueprint_construction' }] };
}
return ok({ blueprintConstructions: { nodes: [row] } });
};
const adapter: modules.GraphQLAdapter = {
execute: async <T>(document: string) => answer(document) as modules.QueryResult<T>,
};
return { client: modules.createClient({ adapter }), documents, reads: () => reads };
}

// A clock that only moves when the wait sleeps.
function fakeTime() {
let t = 0;
return {
now: () => t,
sleep: async (ms: number): Promise<void> => {
t += ms;
},
};
}

const INPUT = { blueprintId: 'bp-1', schemaId: 'schema-1' };

describe('waiting for a blueprint build', () => {
it('polls a queued build until it is constructed and returns its tables', async () => {
const { client, documents } = backend({
statuses: [
{ status: 'pending' },
{ status: 'constructing' },
{ status: 'constructed', tableMap: { books: 'tbl-1', authors: 'tbl-2' } },
],
});

await expect(buildBlueprint(client, INPUT, fakeTime())).resolves.toEqual({
constructionId: 'con-1',
tables: { books: 'tbl-1', authors: 'tbl-2' },
});
// The status reads never name `error`, so they stay valid on older schemas.
expect(documents.filter((doc) => /\berror\b/.test(doc))).toEqual([]);
});

it.each([
[
'a coded error',
{ code: 'BLUEPRINT_MODULE_NOT_INSTALLED', context: { module: 'agent' }, class: 'public' },
'The blueprint build failed: BLUEPRINT_MODULE_NOT_INSTALLED {"module":"agent"}',
],
[
'a database error',
{ code: '57014', message: 'canceling statement due to statement timeout' },
'The blueprint build failed: 57014: canceling statement due to statement timeout',
],
])('surfaces %s of a failed build', async (_kind, error, message) => {
const { client } = backend({ statuses: [{ status: 'pending' }, { status: 'failed' }], error });

await expect(buildBlueprint(client, INPUT, fakeTime())).rejects.toThrow(message);
});

it('fails clearly when the schema has no error column', async () => {
const { client } = backend({ statuses: [{ status: 'failed' }], error: NO_ERROR_COLUMN });

await expect(buildBlueprint(client, INPUT, fakeTime())).rejects.toThrow(
/^The blueprint build failed: the backend gave no reason \(reading it failed: .*Cannot query field "error"/,
);
});

it('gives up at the deadline and says the build may still finish', async () => {
const { client } = backend({ statuses: [{ status: 'pending' }] });

await expect(waitForConstruction(client, 'con-1', fakeTime())).rejects.toThrow(
/still waiting in the backend's build queue after 240 s\. The build may still finish/,
);
});

it('stops polling as soon as the signal aborts', async () => {
const controller = new AbortController();
const { client, reads } = backend({
statuses: [{ status: 'pending' }],
onRead: () => controller.abort(),
});

// A short deadline, so a wait that ignored the signal fails fast instead
// of polling for minutes.
const options = { signal: controller.signal, pollMs: 1_000, deadlineMs: 1_000 };
await expect(waitForConstruction(client, 'con-1', options)).rejects.toThrow(
/^Stopped waiting for the blueprint build/,
);
expect(reads()).toBe(1);
});
});

describe('provision_blueprint', () => {
async function provision(client: ReturnType<typeof backend>['client']) {
mockResolve.mockResolvedValue({
context: { modules: client, databaseId: 'db-1', schemaId: 'schema-1', ownerId: 'user-1' },
reason: '',
} as never);
const ctx: HarnessToolContext = { cwd: '/tmp/project' };
const result = await provisionBlueprintTool.execute(
{
name: 'library',
definition: { tables: [{ table_name: 'books', fields: [], policies: [] }] },
} as never,
ctx,
);
return result.details;
}

it('reports a failed build instead of the tables it asked for', async () => {
const { client } = backend({
statuses: [{ status: 'failed' }],
error: { code: 'RELATION_TARGET_NOT_FOUND', context: { target_table: 'authors' }, class: 'public' },
});

expect(await provision(client)).toMatchObject({
created: 0,
error: 'The blueprint build failed: RELATION_TARGET_NOT_FOUND {"target_table":"authors"}',
});
});

// A queued build carries on without the call, so the host must not call
// again; a construct that never answered queued nothing.
it.each([
['its status could not be read after the build was queued', { statuses: [REFUSED] }, 'con-1'],
['the construct request never answered', { statuses: [], construct: DROPPED }, null],
] as const)('carries the construction id only once a build is queued: %s', async (_case, setup, id) => {
const { client } = backend(setup);

const details = await provision(client);
expect(details.error).toBeTruthy();
expect(details.constructionId).toBe(id);
});
});
54 changes: 53 additions & 1 deletion agentic/db-tools/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ let dir: string;

beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), 'pi-context-'));
mockProbe.mockResolvedValue({ outcome: 'found', name: 'myapp', ownerId: 'u1' });
mockProbe.mockResolvedValue({ status: 'found', databaseId: 'db-1', name: 'myapp', ownerId: 'u1', hash: 'h1' });
mockCreateClient.mockReturnValue(schemaClient);
configureHost({
account: () => ({ userId: 'u1', accessToken: 'account-bearer' }),
Expand Down Expand Up @@ -134,6 +134,58 @@ describe('resolveProjectContext sources', () => {
});
});

describe('resolveProjectContext binding health', () => {
const source = fromEnvironment({
[`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'project-key',
[`${CONTEXT_ENV_PREFIX}DATABASE_ID`]: 'db-1',
});

it("carries the database's hash from the probe", async () => {
const resolved = await resolveProjectContext(source);

expect(resolved.context?.hash).toBe('h1');
});

it('reports a suspended database as db-suspended, never as missing', async () => {
mockProbe.mockResolvedValue({
status: 'suspended',
databaseId: 'db-1',
ownerId: 'u1',
suspendedAt: '2026-10-01T00:00:00Z',
});

const resolved = await resolveProjectContext(source);

expect(resolved).toMatchObject({ context: null, code: 'db-suspended' });
expect(resolved.reason).not.toMatch(/reprovision/i);
});

it('reports an unknown backend error as unreachable with its cause', async () => {
mockProbe.mockResolvedValue({ status: 'unreachable', databaseId: 'db-1', cause: 'error', detail: 'boom' });

const resolved = await resolveProjectContext(source);

expect(resolved).toMatchObject({ context: null, code: 'backend-unreachable', cause: 'error' });
expect(resolved.reason).toMatch(/answered with an error/);
});

it("gives the host's reprovision advice for a missing database instead of the tool's", async () => {
configureHost({
account: () => ({ userId: 'u1', accessToken: 'account-bearer' }),
backendConfig: () => null,
reprovisionHint: 'Approve the rebuild card to re-provision.',
toolReprovision: false,
});
mockProbe.mockResolvedValue({ status: 'missing', databaseId: 'db-1' });

const resolved = await resolveProjectContext(source);

expect(resolved.code).toBe('db-missing');
expect(resolved.reason).toMatch(/Approve the rebuild card/);
expect(resolved.reason).not.toMatch(/reprovision: true/);
});
});

describe('resolveProjectContext schema resolution', () => {
const source = fromEnvironment({
[`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'project-key',
Expand Down
4 changes: 3 additions & 1 deletion agentic/db-tools/__tests__/create-api-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function mockFetchWith(entityIdsSupported: boolean) {
},
}
: { data: { createPrincipal: { result: 'prin-1' } } };
return { json: async () => json };
return { ok: true, status: 200, json: async () => json };
});
}

Expand Down Expand Up @@ -187,6 +187,8 @@ describe('create_api_key execute', () => {
mockGetHost.mockReturnValue(makeHost() as never);
mockToken.mockResolvedValue({ token: 'tok' });
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
data: { __type: { inputFields: [{ name: 'name' }, { name: 'entityIds' }] } },
}),
Expand Down
Loading
Loading