@cosmicdrift/kumiko-framework 0.235.2 → 0.235.4
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/package.json +3 -3
- package/src/__tests__/upgrade-cli.test.ts +85 -0
- package/src/db/queries/event-consumer.ts +27 -0
- package/src/engine/__tests__/build-app-schema.test.ts +2 -0
- package/src/engine/access.ts +4 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +95 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +2 -2
- package/src/engine/feature-ast/extractors/handlers.ts +91 -14
- package/src/engine/feature-ast/patterns.ts +31 -1
- package/src/engine/feature-ast/render.ts +4 -0
- package/src/engine/feature-entity-handlers.ts +28 -2
- package/src/engine/index.ts +4 -1
- package/src/engine/pattern-library/mixed-schemas.ts +28 -0
- package/src/engine/registry-facade.ts +4 -0
- package/src/engine/types/index.ts +4 -0
- package/src/errors/__tests__/error-helpers.test.ts +15 -1
- package/src/errors/index.ts +2 -2
- package/src/errors/reasons.ts +30 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +36 -2
- package/src/migrations/pending-rebuilds.ts +16 -10
- package/src/pipeline/__tests__/event-dispatcher-pass-failure.integration.test.ts +266 -0
- package/src/pipeline/event-dispatcher-delivery.ts +14 -0
- package/src/pipeline/event-dispatcher.ts +68 -1
- package/src/upgrade-cli.ts +37 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.235.
|
|
3
|
+
"version": "0.235.4",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.235.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.235.4",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.235.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.235.4",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -507,3 +507,88 @@ describe("upgrade command — --apply", () => {
|
|
|
507
507
|
expect(existsSync(join(target, ".kumiko/upgrade-state.json"))).toBe(true);
|
|
508
508
|
});
|
|
509
509
|
});
|
|
510
|
+
|
|
511
|
+
// Bug: without --from, the filter baseline used to be the installed
|
|
512
|
+
// version, not the recorded marker — a bare `--apply` always reported
|
|
513
|
+
// "Nothing new" and bootstrapped the marker onto the installed version,
|
|
514
|
+
// hiding changelog entries the marker never actually saw (fw#2371-style).
|
|
515
|
+
describe("upgrade command — filter baseline is the marker, not the installed version", () => {
|
|
516
|
+
const marker = (version: string): string =>
|
|
517
|
+
JSON.stringify({ version, appliedAt: "2024-01-01T00:00:00Z", codemods: [] });
|
|
518
|
+
|
|
519
|
+
test("bare --apply with a marker below installed: the entry between them is pending, not 'Nothing new'", async () => {
|
|
520
|
+
const cwd = tmp({
|
|
521
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
522
|
+
"packages/bundled-features/package.json": JSON.stringify({ version: "0.190.0" }),
|
|
523
|
+
".kumiko/upgrade-state.json": marker("0.160.0"),
|
|
524
|
+
"legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE,
|
|
525
|
+
});
|
|
526
|
+
const spy = makeSpyOutput();
|
|
527
|
+
|
|
528
|
+
const exit = await runUpgradeCli(["--apply"], cwd, spy.out, { repoRoot: REAL_REPO_ROOT });
|
|
529
|
+
|
|
530
|
+
expect(exit).toBe(0);
|
|
531
|
+
expect(spy.logs.join("\n")).not.toContain("Nothing new since your version");
|
|
532
|
+
const rewritten = readFileSync(join(cwd, "legacy-test-helper.ts"), "utf-8");
|
|
533
|
+
expect(rewritten).toContain('from "@cosmicdrift/kumiko-framework/testing"');
|
|
534
|
+
const updatedMarker = JSON.parse(
|
|
535
|
+
readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"),
|
|
536
|
+
);
|
|
537
|
+
expect(updatedMarker.version).toBe("0.167.0");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("bare --apply with a manual breaking change: marker is not stamped to installed, pendingManual records it", async () => {
|
|
541
|
+
const manualEntry = { version: "0.185.0", type: "breaking", title: "manual breaking change" };
|
|
542
|
+
const fixEntry = { version: "0.188.0", type: "fix", title: "later fix" };
|
|
543
|
+
const cwd = tmp({
|
|
544
|
+
"packages/framework/src/changes.json": JSON.stringify([manualEntry, fixEntry]),
|
|
545
|
+
"packages/bundled-features/package.json": JSON.stringify({ version: "0.195.0" }),
|
|
546
|
+
".kumiko/upgrade-state.json": marker("0.180.0"),
|
|
547
|
+
});
|
|
548
|
+
const spy = makeSpyOutput();
|
|
549
|
+
|
|
550
|
+
const exit = await runUpgradeCli(["--apply"], cwd, spy.out, { repoRoot: REAL_REPO_ROOT });
|
|
551
|
+
|
|
552
|
+
expect(exit).toBe(0);
|
|
553
|
+
expect(spy.logs.join("\n")).toContain("no codemod, manual migration required");
|
|
554
|
+
const updatedMarker = JSON.parse(
|
|
555
|
+
readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"),
|
|
556
|
+
);
|
|
557
|
+
// Stamped to the trailing non-breaking entry's version (0.188.0), not
|
|
558
|
+
// blindly hoisted to the installed version (0.195.0) — proof both
|
|
559
|
+
// entries were actually seen and run through markerVersionForPending,
|
|
560
|
+
// rather than filtered out entirely by a too-high baseline.
|
|
561
|
+
expect(updatedMarker.version).toBe("0.188.0");
|
|
562
|
+
expect(updatedMarker.version).not.toBe("0.195.0");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("bare --apply with no marker (bootstrap): behaves as before, marker is written at the installed version", async () => {
|
|
566
|
+
const cwd = tmp({
|
|
567
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
568
|
+
"packages/framework/package.json": JSON.stringify({ version: "0.170.0" }),
|
|
569
|
+
});
|
|
570
|
+
const spy = makeSpyOutput();
|
|
571
|
+
|
|
572
|
+
const exit = await runUpgradeCli(["--apply"], cwd, spy.out, { repoRoot: REAL_REPO_ROOT });
|
|
573
|
+
|
|
574
|
+
expect(exit).toBe(0);
|
|
575
|
+
expect(spy.logs.join("\n")).toContain("Nothing new since your version");
|
|
576
|
+
const updatedMarker = JSON.parse(
|
|
577
|
+
readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"),
|
|
578
|
+
);
|
|
579
|
+
expect(updatedMarker.version).toBe("0.170.0");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("--from wins over an existing marker", async () => {
|
|
583
|
+
const cwd = tmp({
|
|
584
|
+
"packages/framework/src/changes.json": JSON.stringify([
|
|
585
|
+
{ version: "0.175.0", type: "fix", title: "explicit-from-test-entry" },
|
|
586
|
+
]),
|
|
587
|
+
".kumiko/upgrade-state.json": marker("0.180.0"),
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const result = await runJson(cwd, "0.170.0");
|
|
591
|
+
|
|
592
|
+
expect(result.pending.map((e) => e.title)).toEqual(["explicit-from-test-entry"]);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
@@ -57,6 +57,33 @@ export async function markConsumerProcessing(
|
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Best-effort record of an infra-level pass failure (event-dispatcher.ts's
|
|
61
|
+
// processConsumer catch) — called OUTSIDE the transaction that just rolled
|
|
62
|
+
// back, since that tx's own markProcessing/updateConsumerDeliveryOutcome
|
|
63
|
+
// writes never committed. Must NOT touch "attempts": that counter is
|
|
64
|
+
// deliverEvents' poison-pill/dead-letter budget, incremented only on a
|
|
65
|
+
// handler throw inside an actual delivery pass. Spending it here would let
|
|
66
|
+
// repeated infra blips (the exact case this backoff exists for) dead-letter
|
|
67
|
+
// the consumer on the next real handler failure instead of after
|
|
68
|
+
// maxAttempts. Visibility is covered by last_error, updated_at, and the
|
|
69
|
+
// caller's console.error — status and the cursor are left as-is too, since
|
|
70
|
+
// we don't know at this point whether the consumer should be "dead" (that's
|
|
71
|
+
// the deliverEvents/maxAttempts contract, which never ran this pass).
|
|
72
|
+
export async function recordConsumerPassFailure(
|
|
73
|
+
db: AnyDb,
|
|
74
|
+
name: string,
|
|
75
|
+
instanceId: string,
|
|
76
|
+
errorMessage: string,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
await asRawClient(db).unsafe(
|
|
79
|
+
`UPDATE "kumiko_event_consumers" SET
|
|
80
|
+
"last_error" = $1,
|
|
81
|
+
"updated_at" = now()
|
|
82
|
+
WHERE "name" = $2 AND "instance_id" = $3`,
|
|
83
|
+
[errorMessage, name, instanceId],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
export type ConsumerDeliveryOutcome = {
|
|
61
88
|
readonly cursor: bigint;
|
|
62
89
|
readonly attempts: number;
|
|
@@ -432,6 +432,7 @@ describe("buildAppSchema", () => {
|
|
|
432
432
|
"allowPlaintext", // PII-audit reason string, not a renderer concern
|
|
433
433
|
"lookupable", // blind-index equality lookup, server-side query concern
|
|
434
434
|
"subjectRef", // GDPR-hook-coverage marker, not a renderer concern
|
|
435
|
+
"description", // AI-agent manifest hint, never rendered to the user
|
|
435
436
|
] as const;
|
|
436
437
|
|
|
437
438
|
type Classified = (typeof FORWARDED_KEYS)[number] | (typeof SERVER_ONLY_KEYS)[number];
|
|
@@ -459,6 +460,7 @@ describe("buildAppSchema", () => {
|
|
|
459
460
|
allowPlaintext: "is_business_data",
|
|
460
461
|
lookupable: true,
|
|
461
462
|
subjectRef: true,
|
|
463
|
+
description: "tags for the thing",
|
|
462
464
|
},
|
|
463
465
|
},
|
|
464
466
|
} as unknown as EntityDefinition;
|
package/src/engine/access.ts
CHANGED
|
@@ -12,7 +12,10 @@ import type { AccessRule, SessionUser } from "./types";
|
|
|
12
12
|
// list `anonymous` in `roles` (e.g. `roles: ["anonymous", "customer"]`).
|
|
13
13
|
// Without this guard, enabling `anonymousAccess` on the server would silently
|
|
14
14
|
// turn every existing `openToAll: true` handler into a public endpoint.
|
|
15
|
-
export function hasAccess(
|
|
15
|
+
export function hasAccess(
|
|
16
|
+
user: Pick<SessionUser, "roles">,
|
|
17
|
+
access: AccessRule | undefined,
|
|
18
|
+
): boolean {
|
|
16
19
|
if (!access) return false;
|
|
17
20
|
if ("openToAll" in access) {
|
|
18
21
|
if (access.openToAll !== true) return false;
|
|
@@ -1588,6 +1588,63 @@ defineFeature("f", (r) => {
|
|
|
1588
1588
|
});
|
|
1589
1589
|
});
|
|
1590
1590
|
|
|
1591
|
+
test("object form: description + agent hints are extracted", () => {
|
|
1592
|
+
const result = parseInline(`
|
|
1593
|
+
defineFeature("f", (r) => {
|
|
1594
|
+
r.writeHandler({
|
|
1595
|
+
name: "task:approve",
|
|
1596
|
+
schema: z.object({ id: z.string() }),
|
|
1597
|
+
handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
|
|
1598
|
+
description: "Approves a pending task.",
|
|
1599
|
+
agent: { expose: true, risk: "high" },
|
|
1600
|
+
});
|
|
1601
|
+
});
|
|
1602
|
+
`);
|
|
1603
|
+
|
|
1604
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1605
|
+
kind: "writeHandler",
|
|
1606
|
+
handlerName: "task:approve",
|
|
1607
|
+
description: "Approves a pending task.",
|
|
1608
|
+
agent: { expose: true, risk: "high" },
|
|
1609
|
+
});
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
test("object form: an unknown agent.risk value is dropped, not passed through", () => {
|
|
1613
|
+
const result = parseInline(`
|
|
1614
|
+
defineFeature("f", (r) => {
|
|
1615
|
+
r.writeHandler({
|
|
1616
|
+
name: "task:approve",
|
|
1617
|
+
schema: z.object({ id: z.string() }),
|
|
1618
|
+
handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
|
|
1619
|
+
agent: { expose: true, risk: "bogus" },
|
|
1620
|
+
});
|
|
1621
|
+
});
|
|
1622
|
+
`);
|
|
1623
|
+
|
|
1624
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1625
|
+
kind: "writeHandler",
|
|
1626
|
+
agent: { expose: true },
|
|
1627
|
+
});
|
|
1628
|
+
expect((result.patterns[0] as { agent?: { risk?: unknown } }).agent?.risk).toBeUndefined();
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
test("4-argument form: description + agent hints are read from the options object", () => {
|
|
1632
|
+
const result = parseInline(`
|
|
1633
|
+
defineFeature("f", (r) => {
|
|
1634
|
+
r.writeHandler("task:create", z.object({ title: z.string() }), async (event, ctx) => {
|
|
1635
|
+
return { isSuccess: true, data: {} };
|
|
1636
|
+
}, { description: "Creates a task.", agent: { risk: "mid" } });
|
|
1637
|
+
});
|
|
1638
|
+
`);
|
|
1639
|
+
|
|
1640
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1641
|
+
kind: "writeHandler",
|
|
1642
|
+
handlerName: "task:create",
|
|
1643
|
+
description: "Creates a task.",
|
|
1644
|
+
agent: { risk: "mid" },
|
|
1645
|
+
});
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1591
1648
|
test("resolves a same-file identifier arg to its object-literal form (#1007)", () => {
|
|
1592
1649
|
const result = parseInline(`
|
|
1593
1650
|
const h = {
|
|
@@ -1636,6 +1693,27 @@ defineFeature("f", (r) => {
|
|
|
1636
1693
|
handlerName: "task:list",
|
|
1637
1694
|
});
|
|
1638
1695
|
});
|
|
1696
|
+
|
|
1697
|
+
test("object form: description + agent hints are extracted", () => {
|
|
1698
|
+
const result = parseInline(`
|
|
1699
|
+
defineFeature("f", (r) => {
|
|
1700
|
+
r.queryHandler({
|
|
1701
|
+
name: "task:list",
|
|
1702
|
+
schema: z.object({}),
|
|
1703
|
+
handler: async (q, ctx) => [],
|
|
1704
|
+
description: "Lists tasks.",
|
|
1705
|
+
agent: { risk: "low" },
|
|
1706
|
+
});
|
|
1707
|
+
});
|
|
1708
|
+
`);
|
|
1709
|
+
|
|
1710
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1711
|
+
kind: "queryHandler",
|
|
1712
|
+
handlerName: "task:list",
|
|
1713
|
+
description: "Lists tasks.",
|
|
1714
|
+
agent: { risk: "low" },
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1639
1717
|
});
|
|
1640
1718
|
|
|
1641
1719
|
describe("extractStreamHandler", () => {
|
|
@@ -1688,6 +1766,23 @@ defineFeature("f", (r) => {
|
|
|
1688
1766
|
});
|
|
1689
1767
|
expect(result.errors).toEqual([]);
|
|
1690
1768
|
});
|
|
1769
|
+
|
|
1770
|
+
test("description/agent are NOT part of StreamHandlerPattern's runtime shape", () => {
|
|
1771
|
+
const result = parseInline(`
|
|
1772
|
+
defineFeature("f", (r) => {
|
|
1773
|
+
r.streamHandler({
|
|
1774
|
+
name: "chat:complete",
|
|
1775
|
+
schema: z.object({ prompt: z.string() }),
|
|
1776
|
+
handler: async function* (input, ctx) { yield "token"; },
|
|
1777
|
+
description: "Should be ignored for streamHandler.",
|
|
1778
|
+
agent: { risk: "high" },
|
|
1779
|
+
});
|
|
1780
|
+
});
|
|
1781
|
+
`);
|
|
1782
|
+
|
|
1783
|
+
expect(result.patterns[0]).not.toHaveProperty("description");
|
|
1784
|
+
expect(result.patterns[0]).not.toHaveProperty("agent");
|
|
1785
|
+
});
|
|
1691
1786
|
});
|
|
1692
1787
|
|
|
1693
1788
|
describe("extractJob", () => {
|
|
@@ -201,14 +201,14 @@ defineFeature("workflow", (r) => {
|
|
|
201
201
|
async (event, ctx) => {
|
|
202
202
|
return { isSuccess: true, data: { id: "x" } };
|
|
203
203
|
},
|
|
204
|
-
{ access: { roles: ["user"] } },
|
|
204
|
+
{ access: { roles: ["user"] }, description: "Creates a step.", agent: { expose: true, risk: "high" } },
|
|
205
205
|
);
|
|
206
206
|
|
|
207
207
|
r.queryHandler(
|
|
208
208
|
"step:list",
|
|
209
209
|
z.object({}),
|
|
210
210
|
async (q, ctx) => [],
|
|
211
|
-
{ access: { openToAll: true } },
|
|
211
|
+
{ access: { openToAll: true }, description: "Lists steps.", agent: { risk: "low" } },
|
|
212
212
|
);
|
|
213
213
|
|
|
214
214
|
r.streamHandler(
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import type { CallExpression, Node, SourceFile } from "ts-morph";
|
|
1
|
+
import type { CallExpression, Node, ObjectLiteralExpression, SourceFile } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AccessRule,
|
|
5
|
+
AgentHandlerHints,
|
|
6
|
+
AgentRisk,
|
|
7
|
+
RateLimitOption,
|
|
8
|
+
} from "../../types/handlers";
|
|
4
9
|
import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
|
|
5
10
|
import type { SourceLocation } from "../source-location";
|
|
6
11
|
import { sourceLocationFromNode } from "../source-location";
|
|
@@ -23,10 +28,78 @@ export type ParsedHandlerCall = {
|
|
|
23
28
|
readonly schemaSource?: SourceLocation;
|
|
24
29
|
readonly handlerBody?: SourceLocation;
|
|
25
30
|
readonly access?: AccessRule;
|
|
31
|
+
readonly description?: string;
|
|
32
|
+
readonly agent?: AgentHandlerHints;
|
|
26
33
|
readonly rateLimit?: RateLimitOption;
|
|
27
34
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
28
35
|
};
|
|
29
36
|
|
|
37
|
+
const AGENT_RISK_VALUES: readonly AgentRisk[] = ["low", "mid", "high"];
|
|
38
|
+
|
|
39
|
+
function isAgentRisk(value: unknown): value is AgentRisk {
|
|
40
|
+
return typeof value === "string" && (AGENT_RISK_VALUES as readonly string[]).includes(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readOptionalAgentHints(value: unknown): AgentHandlerHints | undefined {
|
|
44
|
+
if (!isPlainObject(value)) return undefined;
|
|
45
|
+
const expose = typeof value["expose"] === "boolean" ? value["expose"] : undefined;
|
|
46
|
+
const risk = isAgentRisk(value["risk"]) ? value["risk"] : undefined;
|
|
47
|
+
if (expose === undefined && risk === undefined) return undefined;
|
|
48
|
+
return {
|
|
49
|
+
...(expose !== undefined && { expose }),
|
|
50
|
+
...(risk !== undefined && { risk }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads the two AI-agent-manifest slots (`description`, `agent`) off an
|
|
56
|
+
* object-form handler-call literal. Factored out of parseHandlerCall to
|
|
57
|
+
* keep that function's branching flat — both fields are optional and
|
|
58
|
+
* independent of the rest of the header (access/rateLimit/schema/handler).
|
|
59
|
+
*/
|
|
60
|
+
function readDescriptionAndAgent(
|
|
61
|
+
obj: ObjectLiteralExpression,
|
|
62
|
+
): Pick<ParsedHandlerCall, "description" | "agent"> {
|
|
63
|
+
const descriptionLiteral = obj
|
|
64
|
+
.getProperty("description")
|
|
65
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
66
|
+
?.getInitializer()
|
|
67
|
+
?.asKind(SyntaxKind.StringLiteral);
|
|
68
|
+
const agentInit = obj
|
|
69
|
+
.getProperty("agent")
|
|
70
|
+
?.asKind(SyntaxKind.PropertyAssignment)
|
|
71
|
+
?.getInitializer();
|
|
72
|
+
const agent = agentInit ? readOptionalAgentHints(readDataLiteralNode(agentInit)) : undefined;
|
|
73
|
+
return {
|
|
74
|
+
...(descriptionLiteral !== undefined && { description: descriptionLiteral.getLiteralValue() }),
|
|
75
|
+
...(agent !== undefined && { agent }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads `access`/`rateLimit`/`description`/`agent` off the positional
|
|
81
|
+
* 4th-argument options object (the inline-authoring form). Mirrors
|
|
82
|
+
* readDescriptionAndAgent's role for the object-form branch — keeping
|
|
83
|
+
* parseHandlerCall's positional branch to a single assignment instead of
|
|
84
|
+
* four independent mutable locals.
|
|
85
|
+
*/
|
|
86
|
+
function readOptionsFields(
|
|
87
|
+
options: unknown,
|
|
88
|
+
): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent"> {
|
|
89
|
+
if (!isPlainObject(options)) return {};
|
|
90
|
+
const access = readOptionalAccessRule(options["access"]);
|
|
91
|
+
const rateLimit = readOptionalRateLimit(options["rateLimit"]);
|
|
92
|
+
const description =
|
|
93
|
+
typeof options["description"] === "string" ? options["description"] : undefined;
|
|
94
|
+
const agent = readOptionalAgentHints(options["agent"]);
|
|
95
|
+
return {
|
|
96
|
+
...(access !== undefined && { access }),
|
|
97
|
+
...(description !== undefined && { description }),
|
|
98
|
+
...(agent !== undefined && { agent }),
|
|
99
|
+
...(rateLimit !== undefined && { rateLimit }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
30
103
|
/**
|
|
31
104
|
* Resolves an argument standing in for a handler-call's object-form body:
|
|
32
105
|
* either it's already an object literal, or a bare identifier declared
|
|
@@ -113,6 +186,7 @@ export function parseHandlerCall(
|
|
|
113
186
|
const rateLimit = rateLimitInit
|
|
114
187
|
? readOptionalRateLimit(readDataLiteralNode(rateLimitInit))
|
|
115
188
|
: undefined;
|
|
189
|
+
const { description, agent } = readDescriptionAndAgent(obj);
|
|
116
190
|
const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
|
|
117
191
|
return ok({
|
|
118
192
|
source: sourceLocationFromNode(call, sourceFile),
|
|
@@ -120,6 +194,8 @@ export function parseHandlerCall(
|
|
|
120
194
|
schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
|
|
121
195
|
handlerBody: sourceLocationFromNode(fn, sourceFile),
|
|
122
196
|
...(access !== undefined && { access }),
|
|
197
|
+
...(description !== undefined && { description }),
|
|
198
|
+
...(agent !== undefined && { agent }),
|
|
123
199
|
...(rateLimit !== undefined && { rateLimit }),
|
|
124
200
|
...(skip === true && { unsafeSkipTransitionGuard: true }),
|
|
125
201
|
});
|
|
@@ -168,22 +244,13 @@ export function parseHandlerCall(
|
|
|
168
244
|
);
|
|
169
245
|
}
|
|
170
246
|
const optionsArg = args[3];
|
|
171
|
-
|
|
172
|
-
let rateLimit: RateLimitOption | undefined;
|
|
173
|
-
if (optionsArg) {
|
|
174
|
-
const options = readDataLiteralNode(optionsArg);
|
|
175
|
-
if (isPlainObject(options)) {
|
|
176
|
-
access = readOptionalAccessRule(options["access"]);
|
|
177
|
-
rateLimit = readOptionalRateLimit(options["rateLimit"]);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
247
|
+
const optionsFields = optionsArg ? readOptionsFields(readDataLiteralNode(optionsArg)) : {};
|
|
180
248
|
return ok({
|
|
181
249
|
source: sourceLocationFromNode(call, sourceFile),
|
|
182
250
|
handlerName,
|
|
183
251
|
schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
|
|
184
252
|
handlerBody: sourceLocationFromNode(fn, sourceFile),
|
|
185
|
-
...
|
|
186
|
-
...(rateLimit !== undefined && { rateLimit }),
|
|
253
|
+
...optionsFields,
|
|
187
254
|
});
|
|
188
255
|
}
|
|
189
256
|
|
|
@@ -200,6 +267,8 @@ export function extractWriteHandler(
|
|
|
200
267
|
schemaSource: parsed.pattern.schemaSource,
|
|
201
268
|
handlerBody: parsed.pattern.handlerBody,
|
|
202
269
|
...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
|
|
270
|
+
...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
|
|
271
|
+
...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
|
|
203
272
|
...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
|
|
204
273
|
...(parsed.pattern.unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
|
|
205
274
|
});
|
|
@@ -222,9 +291,17 @@ export function extractQueryHandler(
|
|
|
222
291
|
): ExtractOutput<QueryHandlerPattern> {
|
|
223
292
|
const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
|
|
224
293
|
if (parsed.kind === "error") return parsed;
|
|
225
|
-
return ok({
|
|
294
|
+
return ok({
|
|
295
|
+
kind: "queryHandler",
|
|
296
|
+
...readHandlerFields(parsed),
|
|
297
|
+
...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
|
|
298
|
+
...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
|
|
299
|
+
});
|
|
226
300
|
}
|
|
227
301
|
|
|
302
|
+
// streamHandler shares parseHandlerCall with writeHandler/queryHandler, but
|
|
303
|
+
// StreamHandlerDef carries neither `description` nor `agent` at runtime —
|
|
304
|
+
// readHandlerFields intentionally does not forward them here.
|
|
228
305
|
export function extractStreamHandler(
|
|
229
306
|
call: CallExpression,
|
|
230
307
|
sourceFile: SourceFile,
|
|
@@ -66,7 +66,12 @@ import type {
|
|
|
66
66
|
} from "../types/config";
|
|
67
67
|
import type { MetricOptions, SecretOptions } from "../types/feature";
|
|
68
68
|
import type { EntityDefinition } from "../types/fields";
|
|
69
|
-
import type {
|
|
69
|
+
import type {
|
|
70
|
+
AccessRule,
|
|
71
|
+
AgentHandlerHints,
|
|
72
|
+
ClaimKeyType,
|
|
73
|
+
RateLimitOption,
|
|
74
|
+
} from "../types/handlers";
|
|
70
75
|
import type { HookPhase } from "../types/hooks";
|
|
71
76
|
import type { HttpRouteMethod } from "../types/http-route";
|
|
72
77
|
import type { NavDefinition } from "../types/nav";
|
|
@@ -87,6 +92,10 @@ import type { SourceLocation } from "./source-location";
|
|
|
87
92
|
// framework derives the aggregate table, CRUD events, and the read-side
|
|
88
93
|
// projection from it at boot. Fully static — the Designer renders it as a
|
|
89
94
|
// form, the AI patcher edits it as pure data.
|
|
95
|
+
//
|
|
96
|
+
// Carries `definition.description` transitively (no dedicated field here):
|
|
97
|
+
// that prose feeds the AI-agent manifest output. An entity without it still
|
|
98
|
+
// works, but the agent has nothing to say about it in the tool catalogue.
|
|
90
99
|
export type EntityPattern = {
|
|
91
100
|
readonly kind: "entity";
|
|
92
101
|
readonly source: SourceLocation;
|
|
@@ -333,6 +342,10 @@ export type ScreenOpaqueMarker = typeof SCREEN_OPAQUE_MARKER;
|
|
|
333
342
|
// entity-bound screens reference a registered entity and that column/form
|
|
334
343
|
// field refs name real fields. Closure-valued props (visibility conditions,
|
|
335
344
|
// row-action payloads, custom renderers) stay opaque — see `opaqueProps`.
|
|
345
|
+
//
|
|
346
|
+
// Carries `definition.description` transitively (no dedicated field here):
|
|
347
|
+
// that prose feeds the AI-agent manifest output. A `type: "custom"` screen
|
|
348
|
+
// without it renders a `renderer` closure the agent has no way to explain.
|
|
336
349
|
export type ScreenPattern = {
|
|
337
350
|
readonly kind: "screen";
|
|
338
351
|
readonly source: SourceLocation;
|
|
@@ -346,6 +359,13 @@ export type ScreenPattern = {
|
|
|
346
359
|
// `r.writeHandler(...)` — registers a command handler: name, Zod input
|
|
347
360
|
// schema, handler closure, plus optional `access` and `rateLimit` rules.
|
|
348
361
|
// The header is declarative; schema and body stay opaque source spans.
|
|
362
|
+
//
|
|
363
|
+
// `description` is the prose the AI agent sees as this handler's tool
|
|
364
|
+
// description in the manifest — without it, the handler stays invisible to
|
|
365
|
+
// the agent (fail-closed: no description, no exposure). `agent.expose`
|
|
366
|
+
// overrides that derivation explicitly (force-show or force-hide);
|
|
367
|
+
// `agent.risk` ("low" | "mid" | "high") classifies the action's blast
|
|
368
|
+
// radius, defaulting to "mid" for a write handler.
|
|
349
369
|
export type WriteHandlerPattern = {
|
|
350
370
|
readonly kind: "writeHandler";
|
|
351
371
|
readonly source: SourceLocation;
|
|
@@ -364,6 +384,8 @@ export type WriteHandlerPattern = {
|
|
|
364
384
|
// generates raw TypeScript, no DSL interpretation.
|
|
365
385
|
readonly handlerBody?: SourceLocation;
|
|
366
386
|
readonly access?: AccessRule;
|
|
387
|
+
readonly description?: string;
|
|
388
|
+
readonly agent?: AgentHandlerHints;
|
|
367
389
|
readonly rateLimit?: RateLimitOption;
|
|
368
390
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
369
391
|
};
|
|
@@ -372,6 +394,12 @@ export type WriteHandlerPattern = {
|
|
|
372
394
|
// handler closure, plus optional `access` and `rateLimit` rules. Read-side
|
|
373
395
|
// counterpart of `r.writeHandler` with the same header/body split. Opaque
|
|
374
396
|
// (no handlerName) for the same single-reference case as WriteHandlerPattern.
|
|
397
|
+
//
|
|
398
|
+
// `description` and `agent` mirror WriteHandlerPattern: `description` is the
|
|
399
|
+
// prose the AI agent shows as this query's tool description (absent →
|
|
400
|
+
// invisible to the agent), `agent.expose` overrides that, and `agent.risk`
|
|
401
|
+
// defaults to "low" for a query handler (reads carry less blast radius than
|
|
402
|
+
// writes).
|
|
375
403
|
export type QueryHandlerPattern = {
|
|
376
404
|
readonly kind: "queryHandler";
|
|
377
405
|
readonly source: SourceLocation;
|
|
@@ -379,6 +407,8 @@ export type QueryHandlerPattern = {
|
|
|
379
407
|
readonly schemaSource?: SourceLocation;
|
|
380
408
|
readonly handlerBody?: SourceLocation;
|
|
381
409
|
readonly access?: AccessRule;
|
|
410
|
+
readonly description?: string;
|
|
411
|
+
readonly agent?: AgentHandlerHints;
|
|
382
412
|
readonly rateLimit?: RateLimitOption;
|
|
383
413
|
};
|
|
384
414
|
|
|
@@ -404,6 +404,8 @@ function renderWriteHandler(p: WriteHandlerPattern): string {
|
|
|
404
404
|
lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
|
|
405
405
|
lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
|
|
406
406
|
if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
|
|
407
|
+
if (p.description !== undefined) lines.push(` description: ${JSON.stringify(p.description)},`);
|
|
408
|
+
if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
|
|
407
409
|
if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
|
|
408
410
|
if (p.unsafeSkipTransitionGuard === true) lines.push(" unsafeSkipTransitionGuard: true,");
|
|
409
411
|
lines.push("});");
|
|
@@ -417,6 +419,8 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
|
|
|
417
419
|
lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
|
|
418
420
|
lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
|
|
419
421
|
if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
|
|
422
|
+
if (p.description !== undefined) lines.push(` description: ${JSON.stringify(p.description)},`);
|
|
423
|
+
if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
|
|
420
424
|
if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
|
|
421
425
|
lines.push("});");
|
|
422
426
|
return lines.join("\n");
|
|
@@ -14,6 +14,7 @@ import { resolveName } from "./handler-helpers";
|
|
|
14
14
|
import { splitNamedDefinition } from "./object-form";
|
|
15
15
|
import type {
|
|
16
16
|
AccessRule,
|
|
17
|
+
AgentHandlerHints,
|
|
17
18
|
EntityDefinition,
|
|
18
19
|
EntityRef,
|
|
19
20
|
HandlerRef,
|
|
@@ -54,6 +55,16 @@ function tryMapEntity(state: FeatureBuilderState, name: string, handlerName: str
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
function agentSlots(source: {
|
|
59
|
+
readonly description?: string;
|
|
60
|
+
readonly agent?: AgentHandlerHints;
|
|
61
|
+
}): { description?: string; agent?: AgentHandlerHints } {
|
|
62
|
+
return {
|
|
63
|
+
...(source.description !== undefined && { description: source.description }),
|
|
64
|
+
...(source.agent && { agent: source.agent }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
// Builds entity/relation/writeHandler/queryHandler — the registrar
|
|
58
69
|
// methods that create or reference entities.
|
|
59
70
|
export function buildEntityHandlerMethods<TName extends string>(
|
|
@@ -96,7 +107,12 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
96
107
|
nameOrDef: string | WriteHandlerDefinition<TName, TSchema>,
|
|
97
108
|
schema?: TSchema,
|
|
98
109
|
handler?: WriteHandlerFn<z.infer<TSchema>>,
|
|
99
|
-
options?: {
|
|
110
|
+
options?: {
|
|
111
|
+
access?: AccessRule;
|
|
112
|
+
rateLimit?: RateLimitOption;
|
|
113
|
+
description?: string;
|
|
114
|
+
agent?: AgentHandlerHints;
|
|
115
|
+
},
|
|
100
116
|
): HandlerRef {
|
|
101
117
|
if (typeof nameOrDef === "object") {
|
|
102
118
|
const def = nameOrDef;
|
|
@@ -113,6 +129,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
113
129
|
// is what fails). Explicit cast is the right tool.
|
|
114
130
|
handler: def.handler as WriteHandlerFn,
|
|
115
131
|
...(def.access && { access: def.access }),
|
|
132
|
+
...agentSlots(def),
|
|
116
133
|
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
117
134
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
118
135
|
// Forward the pipeline-build closure so boot-validators and
|
|
@@ -135,6 +152,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
135
152
|
schema,
|
|
136
153
|
handler: handler as WriteHandlerFn, // @cast-boundary engine-bridge
|
|
137
154
|
...(options?.access && { access: options.access }),
|
|
155
|
+
...agentSlots(options ?? {}),
|
|
138
156
|
...(options?.rateLimit && { rateLimit: options.rateLimit }),
|
|
139
157
|
};
|
|
140
158
|
tryMapEntity(state, name, nameOrDef);
|
|
@@ -144,7 +162,13 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
144
162
|
nameOrDef: string | QueryHandlerDefinition<TName, TSchema>,
|
|
145
163
|
schema?: TSchema,
|
|
146
164
|
handler?: QueryHandlerFn<z.infer<TSchema>>,
|
|
147
|
-
options?: {
|
|
165
|
+
options?: {
|
|
166
|
+
access?: AccessRule;
|
|
167
|
+
rateLimit?: RateLimitOption;
|
|
168
|
+
outputSchema?: ZodType;
|
|
169
|
+
description?: string;
|
|
170
|
+
agent?: AgentHandlerHints;
|
|
171
|
+
},
|
|
148
172
|
): HandlerRef {
|
|
149
173
|
if (typeof nameOrDef === "object") {
|
|
150
174
|
const def = nameOrDef;
|
|
@@ -154,6 +178,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
154
178
|
// @cast-boundary engine-bridge — typed Dev-API → erased internal storage
|
|
155
179
|
handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
|
|
156
180
|
...(def.access && { access: def.access }),
|
|
181
|
+
...agentSlots(def),
|
|
157
182
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
158
183
|
...(def.outputSchema && { outputSchema: def.outputSchema }),
|
|
159
184
|
// Carry the definePagedQueryHandler brand through — this rebuild
|
|
@@ -170,6 +195,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
170
195
|
schema,
|
|
171
196
|
handler: handler as QueryHandlerFn, // @cast-boundary engine-bridge
|
|
172
197
|
...(options?.access && { access: options.access }),
|
|
198
|
+
...agentSlots(options ?? {}),
|
|
173
199
|
...(options?.rateLimit && { rateLimit: options.rateLimit }),
|
|
174
200
|
...(options?.outputSchema && { outputSchema: options.outputSchema }),
|
|
175
201
|
};
|
package/src/engine/index.ts
CHANGED
|
@@ -279,6 +279,9 @@ export {
|
|
|
279
279
|
export type {
|
|
280
280
|
AccessRule,
|
|
281
281
|
ActionFormScreenDefinition,
|
|
282
|
+
AgentExposure,
|
|
283
|
+
AgentHandlerHints,
|
|
284
|
+
AgentRisk,
|
|
282
285
|
AppContext,
|
|
283
286
|
AppendEventArgs,
|
|
284
287
|
AppendEventFn,
|
|
@@ -469,7 +472,7 @@ export type {
|
|
|
469
472
|
WriteHandlerFn,
|
|
470
473
|
WriteResult,
|
|
471
474
|
} from "./types";
|
|
472
|
-
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases } from "./types";
|
|
475
|
+
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, resolveAgentExposure } from "./types";
|
|
473
476
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
|
|
474
477
|
export type {
|
|
475
478
|
AwaitedEventType,
|