@stigmer/server 3.38.2 → 3.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/domain/agent/controller.d.ts +2 -2
- package/dist/domain/agent/controller.d.ts.map +1 -1
- package/dist/domain/agent/controller.js +7 -3
- package/dist/domain/agent/controller.js.map +1 -1
- package/dist/domain/agentchannel/controller.d.ts +2 -2
- package/dist/domain/agentchannel/controller.d.ts.map +1 -1
- package/dist/domain/agentchannel/controller.js +10 -5
- package/dist/domain/agentchannel/controller.js.map +1 -1
- package/dist/domain/agentexecution/controller.js +7 -3
- package/dist/domain/agentexecution/controller.js.map +1 -1
- package/dist/domain/agentexecution/update-status.d.ts +9 -2
- package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
- package/dist/domain/agentexecution/update-status.js +28 -5
- package/dist/domain/agentexecution/update-status.js.map +1 -1
- package/dist/domain/agentexecution/validate-thinking-mode.d.ts +31 -21
- package/dist/domain/agentexecution/validate-thinking-mode.d.ts.map +1 -1
- package/dist/domain/agentexecution/validate-thinking-mode.js +61 -18
- package/dist/domain/agentexecution/validate-thinking-mode.js.map +1 -1
- package/dist/domain/agentinstance/controller.d.ts +2 -2
- package/dist/domain/agentinstance/controller.d.ts.map +1 -1
- package/dist/domain/agentinstance/controller.js +7 -3
- package/dist/domain/agentinstance/controller.js.map +1 -1
- package/dist/domain/agentshare/controller.d.ts +2 -2
- package/dist/domain/agentshare/controller.d.ts.map +1 -1
- package/dist/domain/agentshare/controller.js +7 -3
- package/dist/domain/agentshare/controller.js.map +1 -1
- package/dist/domain/artifact/controller.d.ts.map +1 -1
- package/dist/domain/artifact/controller.js +9 -6
- package/dist/domain/artifact/controller.js.map +1 -1
- package/dist/domain/environment/controller.d.ts +2 -2
- package/dist/domain/environment/controller.js +7 -3
- package/dist/domain/environment/controller.js.map +1 -1
- package/dist/domain/environment/steps.d.ts +2 -1
- package/dist/domain/environment/steps.d.ts.map +1 -1
- package/dist/domain/environment/steps.js +10 -7
- package/dist/domain/environment/steps.js.map +1 -1
- package/dist/domain/organization/controller.d.ts.map +1 -1
- package/dist/domain/organization/controller.js +33 -14
- package/dist/domain/organization/controller.js.map +1 -1
- package/dist/domain/organization/slug-ledger.d.ts +86 -0
- package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
- package/dist/domain/organization/slug-ledger.js +128 -0
- package/dist/domain/organization/slug-ledger.js.map +1 -0
- package/dist/domain/organization/steps.d.ts +12 -3
- package/dist/domain/organization/steps.d.ts.map +1 -1
- package/dist/domain/organization/steps.js +28 -6
- package/dist/domain/organization/steps.js.map +1 -1
- package/dist/domain/workflow/registry/data/model-registry.json +23 -14
- package/dist/domain/workflow/registry/model-registry-store.d.ts +14 -3
- package/dist/domain/workflow/registry/model-registry-store.d.ts.map +1 -1
- package/dist/domain/workflow/registry/model-registry-store.js +14 -3
- package/dist/domain/workflow/registry/model-registry-store.js.map +1 -1
- package/dist/extensions/gate-slots.d.ts +7 -5
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/errors.d.ts +8 -0
- package/dist/pipeline/errors.d.ts.map +1 -1
- package/dist/pipeline/errors.js +19 -0
- package/dist/pipeline/errors.js.map +1 -1
- package/dist/store/interface.d.ts +67 -0
- package/dist/store/interface.d.ts.map +1 -1
- package/dist/store/interface.js.map +1 -1
- package/dist/store/organization-slug-history.d.ts +71 -0
- package/dist/store/organization-slug-history.d.ts.map +1 -0
- package/dist/store/organization-slug-history.js +88 -0
- package/dist/store/organization-slug-history.js.map +1 -0
- package/dist/store/postgres/migrations.d.ts +3 -1
- package/dist/store/postgres/migrations.d.ts.map +1 -1
- package/dist/store/postgres/migrations.js +69 -1
- package/dist/store/postgres/migrations.js.map +1 -1
- package/dist/store/postgres/store.d.ts +2 -1
- package/dist/store/postgres/store.d.ts.map +1 -1
- package/dist/store/postgres/store.js +49 -0
- package/dist/store/postgres/store.js.map +1 -1
- package/dist/store/sqlite/migrations.d.ts +3 -1
- package/dist/store/sqlite/migrations.d.ts.map +1 -1
- package/dist/store/sqlite/migrations.js +54 -1
- package/dist/store/sqlite/migrations.js.map +1 -1
- package/dist/store/sqlite/store.d.ts +2 -1
- package/dist/store/sqlite/store.d.ts.map +1 -1
- package/dist/store/sqlite/store.js +55 -0
- package/dist/store/sqlite/store.js.map +1 -1
- package/package.json +6 -6
- package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
- package/src/domain/agent/controller.ts +9 -5
- package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
- package/src/domain/agentchannel/controller.ts +12 -7
- package/src/domain/agentexecution/__tests__/run-gate.test.ts +42 -1
- package/src/domain/agentexecution/__tests__/update-status.test.ts +58 -2
- package/src/domain/agentexecution/__tests__/validate-thinking-mode.test.ts +209 -61
- package/src/domain/agentexecution/controller.ts +7 -3
- package/src/domain/agentexecution/update-status.ts +43 -3
- package/src/domain/agentexecution/validate-thinking-mode.ts +102 -47
- package/src/domain/agentinstance/__tests__/store-faults.test.ts +110 -0
- package/src/domain/agentinstance/controller.ts +9 -5
- package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
- package/src/domain/agentshare/controller.ts +9 -5
- package/src/domain/artifact/__tests__/store-faults.test.ts +117 -0
- package/src/domain/artifact/controller.ts +12 -6
- package/src/domain/environment/__tests__/store-faults.test.ts +150 -0
- package/src/domain/environment/controller.ts +8 -5
- package/src/domain/environment/steps.ts +10 -7
- package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
- package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
- package/src/domain/organization/controller.ts +36 -14
- package/src/domain/organization/slug-ledger.ts +209 -0
- package/src/domain/organization/steps.ts +28 -6
- package/src/domain/workflow/registry/data/model-registry.json +23 -14
- package/src/domain/workflow/registry/model-registry-store.ts +16 -3
- package/src/extensions/gate-slots.ts +7 -5
- package/src/index.ts +11 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +0 -7
- package/src/pipeline/errors.ts +23 -0
- package/src/store/__tests__/organization-slug-history.test.ts +109 -0
- package/src/store/__tests__/store-contract.ts +86 -1
- package/src/store/interface.ts +70 -0
- package/src/store/organization-slug-history.ts +159 -0
- package/src/store/postgres/__tests__/migrations.test.ts +138 -2
- package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
- package/src/store/postgres/migrations.ts +84 -1
- package/src/store/postgres/store.ts +84 -0
- package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
- package/src/store/sqlite/migrations.ts +69 -1
- package/src/store/sqlite/store.ts +84 -0
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* token match / single-term prefix / AND; wire-ready 0–1 scores;
|
|
18
18
|
* list-mode newest-first at exactly 1.0 — search-mode ranking ORDER is
|
|
19
19
|
* deliberately NOT asserted here, it is driver-relative), the two-phase
|
|
20
|
-
* signal-dedupe hold (oss#442),
|
|
20
|
+
* signal-dedupe hold (oss#442), the organization-slug ledger (one winner
|
|
21
|
+
* of concurrent claims, a retire that sticks, a release that frees only
|
|
22
|
+
* its own unretired claim), OAuth grants, once-only pending-state
|
|
21
23
|
* redemption with its 10-minute TTL, the closed-store failure mode, and
|
|
22
24
|
* the list index (../list-index.ts): one organization's or one parent's
|
|
23
25
|
* rows newest first, a cursor walk with no gap and no duplicate, keys
|
|
@@ -1435,6 +1437,86 @@ export function describeStoreContract(
|
|
|
1435
1437
|
});
|
|
1436
1438
|
});
|
|
1437
1439
|
|
|
1440
|
+
describe("organization slugs", () => {
|
|
1441
|
+
it("claims a fresh slug once; a second claim loses to the entry that holds it", async () => {
|
|
1442
|
+
expect(await fx.store.organizationSlugs.find("acme")).toBeUndefined();
|
|
1443
|
+
|
|
1444
|
+
const first = await fx.store.organizationSlugs.claim("acme");
|
|
1445
|
+
expect(first.claimed).toBe(true);
|
|
1446
|
+
expect(first.entry.slug).toBe("acme");
|
|
1447
|
+
expect(first.entry.retiredAt).toBe("");
|
|
1448
|
+
expect(Date.parse(first.entry.claimedAt)).not.toBeNaN();
|
|
1449
|
+
|
|
1450
|
+
const second = await fx.store.organizationSlugs.claim("acme");
|
|
1451
|
+
expect(second.claimed).toBe(false);
|
|
1452
|
+
expect(second.entry).toEqual(first.entry);
|
|
1453
|
+
expect(await fx.store.organizationSlugs.find("acme")).toEqual(
|
|
1454
|
+
first.entry,
|
|
1455
|
+
);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("of concurrent claims of one slug, exactly one wins", async () => {
|
|
1459
|
+
const claims = await Promise.all(
|
|
1460
|
+
Array.from({ length: 8 }, () =>
|
|
1461
|
+
fx.store.organizationSlugs.claim("contended"),
|
|
1462
|
+
),
|
|
1463
|
+
);
|
|
1464
|
+
expect(claims.filter((claim) => claim.claimed)).toHaveLength(1);
|
|
1465
|
+
const winner = claims.find((claim) => claim.claimed)!;
|
|
1466
|
+
for (const loser of claims.filter((claim) => !claim.claimed)) {
|
|
1467
|
+
expect(loser.entry).toEqual(winner.entry);
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it("retire marks a held slug retired, keeps the first time, and records an unknown slug retired", async () => {
|
|
1472
|
+
const { entry } = await fx.store.organizationSlugs.claim("acme");
|
|
1473
|
+
await fx.store.organizationSlugs.retire("acme");
|
|
1474
|
+
const retired = await fx.store.organizationSlugs.find("acme");
|
|
1475
|
+
expect(retired?.claimedAt).toBe(entry.claimedAt);
|
|
1476
|
+
expect(retired?.retiredAt).not.toBe("");
|
|
1477
|
+
|
|
1478
|
+
await fx.store.organizationSlugs.retire("acme");
|
|
1479
|
+
expect(await fx.store.organizationSlugs.find("acme")).toEqual(retired);
|
|
1480
|
+
|
|
1481
|
+
// An organization created before the ledger existed has no entry;
|
|
1482
|
+
// its delete still retires the slug.
|
|
1483
|
+
await fx.store.organizationSlugs.retire("older");
|
|
1484
|
+
const older = await fx.store.organizationSlugs.find("older");
|
|
1485
|
+
expect(older?.retiredAt).not.toBe("");
|
|
1486
|
+
|
|
1487
|
+
const lost = await fx.store.organizationSlugs.claim("older");
|
|
1488
|
+
expect(lost.claimed).toBe(false);
|
|
1489
|
+
expect(lost.entry).toEqual(older);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
it("release frees only its own unretired claim", async () => {
|
|
1493
|
+
const { entry } = await fx.store.organizationSlugs.claim("acme");
|
|
1494
|
+
await fx.store.organizationSlugs.release(entry);
|
|
1495
|
+
expect(await fx.store.organizationSlugs.find("acme")).toBeUndefined();
|
|
1496
|
+
const reclaimed = await fx.store.organizationSlugs.claim("acme");
|
|
1497
|
+
expect(reclaimed.claimed, "a released slug is claimable at once").toBe(
|
|
1498
|
+
true,
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
// Another create's claim of the same slug carries another time, so
|
|
1502
|
+
// a stale release never frees it.
|
|
1503
|
+
await fx.store.organizationSlugs.release({
|
|
1504
|
+
...reclaimed.entry,
|
|
1505
|
+
claimedAt: "2000-01-01T00:00:00.000Z",
|
|
1506
|
+
});
|
|
1507
|
+
expect(await fx.store.organizationSlugs.find("acme")).toEqual(
|
|
1508
|
+
reclaimed.entry,
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
// A retired slug is never freed, whoever asks.
|
|
1512
|
+
await fx.store.organizationSlugs.retire("acme");
|
|
1513
|
+
await fx.store.organizationSlugs.release(reclaimed.entry);
|
|
1514
|
+
expect(
|
|
1515
|
+
(await fx.store.organizationSlugs.find("acme"))?.retiredAt,
|
|
1516
|
+
).not.toBe("");
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1438
1520
|
describe("oauth grants", () => {
|
|
1439
1521
|
const grant: OAuthGrant = {
|
|
1440
1522
|
identityAccountId: "ida_1",
|
|
@@ -1880,6 +1962,9 @@ export function describeStoreContract(
|
|
|
1880
1962
|
await expect(fx.store.signalDedupe.release("o", "k")).rejects.toThrow(
|
|
1881
1963
|
"store is closed",
|
|
1882
1964
|
);
|
|
1965
|
+
await expect(fx.store.organizationSlugs.claim("o")).rejects.toThrow(
|
|
1966
|
+
"store is closed",
|
|
1967
|
+
);
|
|
1883
1968
|
});
|
|
1884
1969
|
});
|
|
1885
1970
|
}
|
package/src/store/interface.ts
CHANGED
|
@@ -328,6 +328,75 @@ export interface SignalDedupeStore {
|
|
|
328
328
|
release(org: string, idempotencyKey: string): Promise<void>;
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
// =============================================================================
|
|
332
|
+
// Organization slugs (the ledger of every slug an organization ever took)
|
|
333
|
+
// =============================================================================
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* One slug's line in the ledger. An organization's id is its slug, so this
|
|
337
|
+
* is also the history of every organization id the store ever issued.
|
|
338
|
+
*/
|
|
339
|
+
export interface OrganizationSlugEntry {
|
|
340
|
+
readonly slug: string;
|
|
341
|
+
/**
|
|
342
|
+
* RFC-3339 time the ledger first recorded the slug: the create's claim,
|
|
343
|
+
* or, for a slug that predates the ledger, the migration or retire that
|
|
344
|
+
* wrote it.
|
|
345
|
+
*/
|
|
346
|
+
readonly claimedAt: string;
|
|
347
|
+
/** RFC-3339 time the organization holding it was deleted; "" while it is held. */
|
|
348
|
+
readonly retiredAt: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A claim's outcome: won with its entry, or lost to the entry that holds the slug. */
|
|
352
|
+
export type OrganizationSlugClaim =
|
|
353
|
+
| { readonly claimed: true; readonly entry: OrganizationSlugEntry }
|
|
354
|
+
| { readonly claimed: false; readonly entry: OrganizationSlugEntry };
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The organization-slug ledger: a slug is claimed once, atomically, by the
|
|
358
|
+
* create that takes it, and it is never released by the organization's
|
|
359
|
+
* delete, which retires it instead. The organization row can go; its slug
|
|
360
|
+
* stays taken for good, so nothing a deleted organization left behind
|
|
361
|
+
* (rows that name it in `metadata.org`, an edition's rows kept by its id)
|
|
362
|
+
* can ever pass to a new holder of the same slug.
|
|
363
|
+
*
|
|
364
|
+
* The ledger, not the organization row, decides "taken". A claim is the
|
|
365
|
+
* create's uniqueness guarantee, which the row cannot give: `saveResource`
|
|
366
|
+
* upserts, so two creates that both read "absent" would otherwise both
|
|
367
|
+
* write, the second over the first.
|
|
368
|
+
*
|
|
369
|
+
* An unretired entry is a slug held by a live organization, or by a create
|
|
370
|
+
* between its claim and its row; a retired entry is a slug whose
|
|
371
|
+
* organization was deleted. A caller tells the two apart, never "no row"
|
|
372
|
+
* alone, because a create in flight has a claim and no row yet.
|
|
373
|
+
*/
|
|
374
|
+
export interface OrganizationSlugStore {
|
|
375
|
+
/**
|
|
376
|
+
* Claims a slug if no entry holds it, atomically: of concurrent claims,
|
|
377
|
+
* exactly one wins. A loser receives the entry that holds the slug,
|
|
378
|
+
* retired or not.
|
|
379
|
+
*/
|
|
380
|
+
claim(slug: string): Promise<OrganizationSlugClaim>;
|
|
381
|
+
/**
|
|
382
|
+
* Marks the slug retired: sets `retiredAt` on its entry when it is
|
|
383
|
+
* empty, or records the slug already retired when no entry exists (an
|
|
384
|
+
* organization created before the ledger). Idempotent; a second retire
|
|
385
|
+
* keeps the first time.
|
|
386
|
+
*/
|
|
387
|
+
retire(slug: string): Promise<void>;
|
|
388
|
+
/**
|
|
389
|
+
* Frees a claim whose create failed before its organization was stored,
|
|
390
|
+
* so the caller's retry can claim again. Guarded: only the unretired
|
|
391
|
+
* entry carrying this exact `claimedAt` is removed, so a release can
|
|
392
|
+
* never free a retired slug or another create's claim. Anything else is
|
|
393
|
+
* a no-op.
|
|
394
|
+
*/
|
|
395
|
+
release(entry: OrganizationSlugEntry): Promise<void>;
|
|
396
|
+
/** The slug's entry, or undefined when no organization ever took it. */
|
|
397
|
+
find(slug: string): Promise<OrganizationSlugEntry | undefined>;
|
|
398
|
+
}
|
|
399
|
+
|
|
331
400
|
// =============================================================================
|
|
332
401
|
// MCP OAuth (Go: pkg/domain/mcpserver/oauth, tables consolidated by v7 / OD-3)
|
|
333
402
|
// =============================================================================
|
|
@@ -863,6 +932,7 @@ export interface Store {
|
|
|
863
932
|
|
|
864
933
|
readonly bootstrapState: BootstrapStateStore;
|
|
865
934
|
readonly signalDedupe: SignalDedupeStore;
|
|
935
|
+
readonly organizationSlugs: OrganizationSlugStore;
|
|
866
936
|
readonly oauthGrants: OAuthGrantStore;
|
|
867
937
|
readonly pendingOAuthStates: PendingOAuthStateStore;
|
|
868
938
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one data migration both store drivers run when the organization-slug
|
|
3
|
+
* ledger arrives: every slug an organization held before the ledger existed
|
|
4
|
+
* is recorded in it, once, before any request is served. The drivers own
|
|
5
|
+
* the SQL (which rows to read, in what pages, how to insert, the
|
|
6
|
+
* transaction); this module owns what is edition- and driver-neutral: WHICH
|
|
7
|
+
* kinds can name an organization and HOW one row's organization is read.
|
|
8
|
+
*
|
|
9
|
+
* What the step records. Two kinds of slug were taken before the ledger:
|
|
10
|
+
*
|
|
11
|
+
* - every live organization's, copied from the organization rows by the
|
|
12
|
+
* driver's SQL alone (an organization's id is its slug), unretired;
|
|
13
|
+
* - every slug a surviving organization-scoped row still names in
|
|
14
|
+
* `metadata.org` while no organization holds it, retired. Such a row
|
|
15
|
+
* outlived an organization the store deleted before the ledger could
|
|
16
|
+
* record it, and it is exactly what a new organization of that slug
|
|
17
|
+
* would inherit: under the built-in authorizer a row's organization link
|
|
18
|
+
* is derived from `metadata.org`, with no creation times compared.
|
|
19
|
+
*
|
|
20
|
+
* A slug whose organization left no row anywhere is not recorded, because
|
|
21
|
+
* nothing in the store remembers it; re-creating that slug inherits nothing.
|
|
22
|
+
*
|
|
23
|
+
* Why a migration and not a boot sweep. The migration chain is the one
|
|
24
|
+
* place that runs exactly once per database, before any request is served
|
|
25
|
+
* (public-visibility-retired.ts makes the same case). A sweep would need a
|
|
26
|
+
* marker, a fault posture, and either a window on its first boot or a boot
|
|
27
|
+
* that waits on it anyway.
|
|
28
|
+
*
|
|
29
|
+
* Why the kind table is frozen here and not read from the live kind
|
|
30
|
+
* configuration. The step is a statement about the store AS IT WAS when the
|
|
31
|
+
* ledger arrived: these are the kinds whose `kind_meta` declared
|
|
32
|
+
* AUTHORIZATION_SCOPE_TYPE_ORGANIZATION at that moment, and a later release
|
|
33
|
+
* that adds or removes a kind must not change what this step does. The list
|
|
34
|
+
* is spelled out with its schemas, the per-consumer kind-to-schema idiom the
|
|
35
|
+
* tree already uses. The `kind` strings are the enum NAMES the drivers'
|
|
36
|
+
* `kind` column holds (proto-fields.ts `apiResourceKindName`).
|
|
37
|
+
*
|
|
38
|
+
* Why an undecodable row fails the step. The row may name a deleted
|
|
39
|
+
* organization, and skipping it would leave that slug free for anyone to
|
|
40
|
+
* take along with everything the row grants. So the driver's transaction
|
|
41
|
+
* rolls back and the boot stops on the row this names, the rule
|
|
42
|
+
* public-visibility-retired.ts keeps for the same reason. A row with no
|
|
43
|
+
* metadata, or an empty organization, names no slug and is not a fault.
|
|
44
|
+
*/
|
|
45
|
+
import type { DescMessage } from "@bufbuild/protobuf";
|
|
46
|
+
import { fromBinary } from "@bufbuild/protobuf";
|
|
47
|
+
import { reflect } from "@bufbuild/protobuf/reflect";
|
|
48
|
+
|
|
49
|
+
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
50
|
+
import { AgentChannelSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
|
|
51
|
+
import { AgentInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
|
|
52
|
+
import { AgentShareSchema } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
|
|
53
|
+
import { ArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/artifact/v1/api_pb";
|
|
54
|
+
import { ChannelAppSchema } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/api_pb";
|
|
55
|
+
import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
|
|
56
|
+
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
57
|
+
import { MemorySchema } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
|
|
58
|
+
import { PluginSchema } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/api_pb";
|
|
59
|
+
import { ScheduleSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
|
|
60
|
+
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
61
|
+
import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
62
|
+
import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
63
|
+
import { WorkflowExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
|
|
64
|
+
import { WorkflowInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/api_pb";
|
|
65
|
+
import { SubscriptionSchema } from "@stigmer/protos/ai/stigmer/billing/subscription/v1/api_pb";
|
|
66
|
+
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
67
|
+
import { IdentityProviderSchema } from "@stigmer/protos/ai/stigmer/iam/identityprovider/v1/api_pb";
|
|
68
|
+
import { InvitationSchema } from "@stigmer/protos/ai/stigmer/iam/invitation/v1/api_pb";
|
|
69
|
+
import { OAuthAppSchema } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/api_pb";
|
|
70
|
+
import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
71
|
+
import { TeamSchema } from "@stigmer/protos/ai/stigmer/iam/team/v1/api_pb";
|
|
72
|
+
|
|
73
|
+
/** One kind whose rows name their organization, with the schema its rows decode through. */
|
|
74
|
+
export interface OrganizationScopedKind {
|
|
75
|
+
/** The enum NAME the drivers' `kind` column holds. */
|
|
76
|
+
readonly kind: string;
|
|
77
|
+
readonly schema: DescMessage;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The organization-scoped kinds when the ledger arrived, in the order
|
|
82
|
+
* `api_resource_kind.proto` declares them. Frozen: see the module header.
|
|
83
|
+
*/
|
|
84
|
+
export const ORGANIZATION_SCOPED_KINDS_AT_LEDGER: ReadonlyArray<OrganizationScopedKind> =
|
|
85
|
+
[
|
|
86
|
+
{ kind: "iam_policy", schema: IamPolicySchema },
|
|
87
|
+
{ kind: "invitation", schema: InvitationSchema },
|
|
88
|
+
{ kind: "identity_provider", schema: IdentityProviderSchema },
|
|
89
|
+
{ kind: "oauth_app", schema: OAuthAppSchema },
|
|
90
|
+
{ kind: "platform_client", schema: PlatformClientSchema },
|
|
91
|
+
{ kind: "team", schema: TeamSchema },
|
|
92
|
+
{ kind: "agent", schema: AgentSchema },
|
|
93
|
+
{ kind: "session", schema: SessionSchema },
|
|
94
|
+
{ kind: "skill", schema: SkillSchema },
|
|
95
|
+
{ kind: "mcp_server", schema: McpServerSchema },
|
|
96
|
+
{ kind: "agent_instance", schema: AgentInstanceSchema },
|
|
97
|
+
{ kind: "agent_share", schema: AgentShareSchema },
|
|
98
|
+
{ kind: "agent_channel", schema: AgentChannelSchema },
|
|
99
|
+
{ kind: "channel_app", schema: ChannelAppSchema },
|
|
100
|
+
{ kind: "workflow", schema: WorkflowSchema },
|
|
101
|
+
{ kind: "workflow_instance", schema: WorkflowInstanceSchema },
|
|
102
|
+
{ kind: "workflow_execution", schema: WorkflowExecutionSchema },
|
|
103
|
+
{ kind: "environment", schema: EnvironmentSchema },
|
|
104
|
+
{ kind: "artifact", schema: ArtifactSchema },
|
|
105
|
+
{ kind: "schedule", schema: ScheduleSchema },
|
|
106
|
+
{ kind: "memory", schema: MemorySchema },
|
|
107
|
+
{ kind: "plugin", schema: PluginSchema },
|
|
108
|
+
{ kind: "subscription", schema: SubscriptionSchema },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/** How many rows of a kind the step decodes per page, so no kind is ever read whole into memory. */
|
|
112
|
+
export const HISTORY_PAGE_SIZE = 500;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The organization a stored row names in `metadata.org`, or "" when it names
|
|
116
|
+
* none (no metadata, or an empty organization). Throws when the bytes do not
|
|
117
|
+
* decode through the kind's schema, or when the schema lacks the metadata
|
|
118
|
+
* shape every resource shares: both fail the step (see the header).
|
|
119
|
+
*/
|
|
120
|
+
export function organizationNamedBy(
|
|
121
|
+
entry: OrganizationScopedKind,
|
|
122
|
+
data: Uint8Array,
|
|
123
|
+
): string {
|
|
124
|
+
const message = fromBinary(entry.schema, data);
|
|
125
|
+
const root = reflect(entry.schema, message);
|
|
126
|
+
const metadataField = root.fields.find((field) => field.name === "metadata");
|
|
127
|
+
if (metadataField === undefined || metadataField.fieldKind !== "message") {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${entry.kind}: the schema declares no metadata message field`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!root.isSet(metadataField)) {
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
const metadata = root.get(metadataField);
|
|
136
|
+
const orgField = metadata.fields.find((field) => field.name === "org");
|
|
137
|
+
if (orgField === undefined || orgField.fieldKind !== "scalar") {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`${entry.kind}: the metadata message declares no org field`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const org = metadata.get(orgField);
|
|
143
|
+
return typeof org === "string" ? org : "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The error a driver throws for a row that does not decode, naming the row,
|
|
148
|
+
* so both drivers stop the boot with the same sentence.
|
|
149
|
+
*/
|
|
150
|
+
export function undecodableRowError(
|
|
151
|
+
entry: OrganizationScopedKind,
|
|
152
|
+
id: string,
|
|
153
|
+
cause: unknown,
|
|
154
|
+
): Error {
|
|
155
|
+
return new Error(
|
|
156
|
+
`${entry.kind} '${id}' cannot be read for the organization it names: ${String(cause)}`,
|
|
157
|
+
{ cause },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* moves every row of the seven kinds that held the retired public level to
|
|
9
9
|
* org and leaves every other row's bytes as they were; a row it cannot
|
|
10
10
|
* decode fails the step and leaves the database at v4. v6 adds the list
|
|
11
|
-
* index's table (its behaviour is the store contract's).
|
|
12
|
-
*
|
|
11
|
+
* index's table (its behaviour is the store contract's). v7 creates the
|
|
12
|
+
* organization-slug ledger and fills it: every live organization unretired,
|
|
13
|
+
* every organization a surviving scoped row names with none live retired,
|
|
14
|
+
* across keyset pages; a scoped row it cannot decode fails the step and
|
|
15
|
+
* leaves the database at v6 with no ledger. A step's starting database is
|
|
16
|
+
* built by running the chain up to the step before it.
|
|
13
17
|
*
|
|
14
18
|
* Gated on TEST_DATABASE_URL (see support.ts): visible skips without a
|
|
15
19
|
* database, always exercised in CI via the ci.stigmer-server service
|
|
@@ -25,11 +29,15 @@ import type { DescMessage } from "@bufbuild/protobuf";
|
|
|
25
29
|
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
26
30
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
27
31
|
|
|
32
|
+
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
33
|
+
|
|
34
|
+
import { HISTORY_PAGE_SIZE } from "../../organization-slug-history.js";
|
|
28
35
|
import { PUBLIC_ROW_KINDS_AT_RETIREMENT } from "../../public-visibility-retired.js";
|
|
29
36
|
import {
|
|
30
37
|
CURRENT_SCHEMA_VERSION,
|
|
31
38
|
SCHEMA_VERSION_1,
|
|
32
39
|
SCHEMA_VERSION_5,
|
|
40
|
+
SCHEMA_VERSION_7,
|
|
33
41
|
runMigrations,
|
|
34
42
|
} from "../migrations.js";
|
|
35
43
|
import { PostgresStore } from "../store.js";
|
|
@@ -88,6 +96,7 @@ describe.skipIf(testDatabaseAdminUrl() === undefined)(
|
|
|
88
96
|
expect(names).toEqual([
|
|
89
97
|
"bootstrap_state",
|
|
90
98
|
"oauth_grant",
|
|
99
|
+
"organization_slugs",
|
|
91
100
|
"pending_oauth_state",
|
|
92
101
|
"resource_audit",
|
|
93
102
|
"resource_list_keys",
|
|
@@ -379,5 +388,132 @@ describe.skipIf(testDatabaseAdminUrl() === undefined)(
|
|
|
379
388
|
}
|
|
380
389
|
});
|
|
381
390
|
});
|
|
391
|
+
describe("v7: the organization-slug ledger records every slug taken before it", () => {
|
|
392
|
+
/** An agent row naming `org`, encoded through its own schema. */
|
|
393
|
+
function agentNaming(id: string, org: string): Buffer {
|
|
394
|
+
return Buffer.from(
|
|
395
|
+
toBinary(
|
|
396
|
+
AgentSchema,
|
|
397
|
+
create(AgentSchema, {
|
|
398
|
+
metadata: { id, name: id, slug: id, org },
|
|
399
|
+
spec: { instructions: "a conformant instruction body" },
|
|
400
|
+
}),
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function sessionNaming(id: string, org: string): Buffer {
|
|
406
|
+
return Buffer.from(
|
|
407
|
+
toBinary(
|
|
408
|
+
SessionSchema,
|
|
409
|
+
create(SessionSchema, {
|
|
410
|
+
metadata: { id, name: id, slug: id, org },
|
|
411
|
+
}),
|
|
412
|
+
),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** A v6 database, the one v7 starts from, and a client on it for the seeding. */
|
|
417
|
+
async function v6Client(): Promise<pg.Client> {
|
|
418
|
+
await migrateTo(db.databaseUrl, SCHEMA_VERSION_7 - 1);
|
|
419
|
+
const client = new pg.Client({ connectionString: db.databaseUrl });
|
|
420
|
+
await client.connect();
|
|
421
|
+
return client;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function ledger(
|
|
425
|
+
client: pg.Client,
|
|
426
|
+
): Promise<Array<{ slug: string; retired: boolean }>> {
|
|
427
|
+
const result = await client.query<{ slug: string; retired: boolean }>(
|
|
428
|
+
`SELECT slug, retired_at IS NOT NULL AS retired FROM organization_slugs ORDER BY slug`,
|
|
429
|
+
);
|
|
430
|
+
return result.rows;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
it("records every live organization unretired and every organization a surviving row names with none live retired", async () => {
|
|
434
|
+
const client = await v6Client();
|
|
435
|
+
try {
|
|
436
|
+
await client.query(
|
|
437
|
+
`INSERT INTO resources (kind, id, data) VALUES
|
|
438
|
+
('organization', 'acme', '\\x00'),
|
|
439
|
+
('agent', 'agt_live', $1),
|
|
440
|
+
('agent', 'agt_gone', $2),
|
|
441
|
+
('session', 'ses_gone', $3),
|
|
442
|
+
('agent', 'agt_orphan', $4),
|
|
443
|
+
('identity_account', 'ida_opaque', '\\xffff')`,
|
|
444
|
+
[
|
|
445
|
+
agentNaming("agt_live", "acme"),
|
|
446
|
+
agentNaming("agt_gone", "deleted-one"),
|
|
447
|
+
sessionNaming("ses_gone", "deleted-two"),
|
|
448
|
+
agentNaming("agt_orphan", ""),
|
|
449
|
+
],
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const reopened = await PostgresStore.open(db.databaseUrl);
|
|
453
|
+
await reopened.close();
|
|
454
|
+
|
|
455
|
+
expect(await ledger(client)).toEqual([
|
|
456
|
+
{ slug: "acme", retired: false },
|
|
457
|
+
{ slug: "deleted-one", retired: true },
|
|
458
|
+
{ slug: "deleted-two", retired: true },
|
|
459
|
+
]);
|
|
460
|
+
} finally {
|
|
461
|
+
await client.end();
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("reads a kind across keyset pages, missing no row past the first page", async () => {
|
|
466
|
+
const client = await v6Client();
|
|
467
|
+
try {
|
|
468
|
+
const rows = HISTORY_PAGE_SIZE + 2;
|
|
469
|
+
for (let i = 0; i < rows; i++) {
|
|
470
|
+
const id = `agt_${String(i).padStart(4, "0")}`;
|
|
471
|
+
// The last row, past the first page, is the only one naming
|
|
472
|
+
// its organization.
|
|
473
|
+
const org = i === rows - 1 ? "past-the-page" : "on-the-page";
|
|
474
|
+
await client.query(
|
|
475
|
+
`INSERT INTO resources (kind, id, data) VALUES ('agent', $1, $2)`,
|
|
476
|
+
[id, agentNaming(id, org)],
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const reopened = await PostgresStore.open(db.databaseUrl);
|
|
481
|
+
await reopened.close();
|
|
482
|
+
|
|
483
|
+
expect(await ledger(client)).toEqual([
|
|
484
|
+
{ slug: "on-the-page", retired: true },
|
|
485
|
+
{ slug: "past-the-page", retired: true },
|
|
486
|
+
]);
|
|
487
|
+
} finally {
|
|
488
|
+
await client.end();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("a scoped row that does not decode fails the step, names the row, and leaves the database at v6 with no ledger", async () => {
|
|
493
|
+
const client = await v6Client();
|
|
494
|
+
try {
|
|
495
|
+
await client.query(
|
|
496
|
+
`INSERT INTO resources (kind, id, data) VALUES ('agent', 'agt_broken', '\\xffffff')`,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
await expect(PostgresStore.open(db.databaseUrl)).rejects.toThrow(
|
|
500
|
+
/migrate to v7: .*agent 'agt_broken' cannot be read for the organization it names/,
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const version = await client.query(
|
|
504
|
+
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_version`,
|
|
505
|
+
);
|
|
506
|
+
expect(Number((version.rows[0] as { version: string }).version)).toBe(
|
|
507
|
+
SCHEMA_VERSION_7 - 1,
|
|
508
|
+
);
|
|
509
|
+
const table = await client.query(
|
|
510
|
+
`SELECT to_regclass('organization_slugs') AS name`,
|
|
511
|
+
);
|
|
512
|
+
expect((table.rows[0] as { name: string | null }).name).toBeNull();
|
|
513
|
+
} finally {
|
|
514
|
+
await client.end();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|
|
382
518
|
},
|
|
383
519
|
);
|
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
*/
|
|
39
39
|
import type { PoolClient } from "pg";
|
|
40
40
|
|
|
41
|
+
import {
|
|
42
|
+
HISTORY_PAGE_SIZE,
|
|
43
|
+
ORGANIZATION_SCOPED_KINDS_AT_LEDGER,
|
|
44
|
+
organizationNamedBy,
|
|
45
|
+
undecodableRowError,
|
|
46
|
+
} from "../organization-slug-history.js";
|
|
41
47
|
import {
|
|
42
48
|
PUBLIC_ROW_KINDS_AT_RETIREMENT,
|
|
43
49
|
movePublicRowToOrg,
|
|
@@ -52,9 +58,11 @@ export const SCHEMA_VERSION_4 = 4;
|
|
|
52
58
|
export const SCHEMA_VERSION_5 = 5;
|
|
53
59
|
/** v6: the list index's columns, key table and indexes (DDL only). */
|
|
54
60
|
export const SCHEMA_VERSION_6 = 6;
|
|
61
|
+
/** v7: the organization-slug ledger, filled with every slug taken before it. */
|
|
62
|
+
export const SCHEMA_VERSION_7 = 7;
|
|
55
63
|
|
|
56
64
|
/** Target version for new databases. */
|
|
57
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
65
|
+
export const CURRENT_SCHEMA_VERSION = SCHEMA_VERSION_7;
|
|
58
66
|
|
|
59
67
|
/**
|
|
60
68
|
* Advisory lock key for the migration chain. Arbitrary but stable 64-bit
|
|
@@ -95,6 +103,7 @@ export async function runMigrations(
|
|
|
95
103
|
[SCHEMA_VERSION_4, migrateToV4],
|
|
96
104
|
[SCHEMA_VERSION_5, migrateToV5],
|
|
97
105
|
[SCHEMA_VERSION_6, migrateToV6],
|
|
106
|
+
[SCHEMA_VERSION_7, migrateToV7],
|
|
98
107
|
];
|
|
99
108
|
|
|
100
109
|
for (const [version, migrate] of chain) {
|
|
@@ -424,3 +433,77 @@ async function migrateToV6(client: PoolClient): Promise<void> {
|
|
|
424
433
|
ON resource_list_keys (kind, key, value, created_at, (id COLLATE "C"));
|
|
425
434
|
`);
|
|
426
435
|
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* v7: the organization-slug ledger (the Store's `organizationSlugs`,
|
|
439
|
+
* interface.ts says what it guarantees), created and filled in one step so
|
|
440
|
+
* it is complete before the first request. organization-slug-history.ts
|
|
441
|
+
* says what the fill records, why it is a migration, why its kind table is
|
|
442
|
+
* frozen, and why an undecodable row fails the step; this step owns the SQL.
|
|
443
|
+
*
|
|
444
|
+
* - `claimed_at` and `retired_at` are ledger time crossing the Store
|
|
445
|
+
* interface, so TEXT holding the exact RFC-3339 strings (the header's
|
|
446
|
+
* convention); `retired_at` is NULL while the slug is held.
|
|
447
|
+
* - Every query is by the primary key, so the table needs no other index.
|
|
448
|
+
* - Live organizations are copied by id (an organization's id is its slug)
|
|
449
|
+
* with no decode. Each organization-scoped kind is then read in keyset
|
|
450
|
+
* pages on `(kind, id)`, the primary key's order, so no kind is ever held
|
|
451
|
+
* in memory whole; the organizations its rows name with no live
|
|
452
|
+
* organization are recorded retired. ON CONFLICT DO NOTHING keeps a live
|
|
453
|
+
* organization's entry unretired whatever its rows say.
|
|
454
|
+
* - The rows are only read, so nothing takes a row lock; the chain's
|
|
455
|
+
* advisory lock already keeps a second instance's boot out of the step.
|
|
456
|
+
*/
|
|
457
|
+
async function migrateToV7(client: PoolClient): Promise<void> {
|
|
458
|
+
await client.query(`
|
|
459
|
+
CREATE TABLE organization_slugs (
|
|
460
|
+
slug TEXT PRIMARY KEY,
|
|
461
|
+
claimed_at TEXT NOT NULL,
|
|
462
|
+
retired_at TEXT
|
|
463
|
+
)
|
|
464
|
+
`);
|
|
465
|
+
const recordedAt = new Date().toISOString();
|
|
466
|
+
await client.query(
|
|
467
|
+
`INSERT INTO organization_slugs (slug, claimed_at)
|
|
468
|
+
SELECT id, $1 FROM resources WHERE kind = 'organization'
|
|
469
|
+
ON CONFLICT (slug) DO NOTHING`,
|
|
470
|
+
[recordedAt],
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const named = new Set<string>();
|
|
474
|
+
for (const entry of ORGANIZATION_SCOPED_KINDS_AT_LEDGER) {
|
|
475
|
+
let after = "";
|
|
476
|
+
for (;;) {
|
|
477
|
+
const page = await client.query<{ id: string; data: Buffer }>(
|
|
478
|
+
`SELECT id, data FROM resources
|
|
479
|
+
WHERE kind = $1 AND id > $2
|
|
480
|
+
ORDER BY id
|
|
481
|
+
LIMIT $3`,
|
|
482
|
+
[entry.kind, after, HISTORY_PAGE_SIZE],
|
|
483
|
+
);
|
|
484
|
+
for (const row of page.rows) {
|
|
485
|
+
let org: string;
|
|
486
|
+
try {
|
|
487
|
+
org = organizationNamedBy(entry, new Uint8Array(row.data));
|
|
488
|
+
} catch (error) {
|
|
489
|
+
throw undecodableRowError(entry, row.id, error);
|
|
490
|
+
}
|
|
491
|
+
if (org !== "") {
|
|
492
|
+
named.add(org);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (page.rows.length < HISTORY_PAGE_SIZE) {
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
after = page.rows[page.rows.length - 1]!.id;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
for (const slug of named) {
|
|
502
|
+
await client.query(
|
|
503
|
+
`INSERT INTO organization_slugs (slug, claimed_at, retired_at)
|
|
504
|
+
VALUES ($1, $2, $2)
|
|
505
|
+
ON CONFLICT (slug) DO NOTHING`,
|
|
506
|
+
[slug, recordedAt],
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|