@nebutra/agent-runtime 0.2.0 → 0.2.2

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.
Files changed (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
package/src/dispatcher.ts DELETED
@@ -1,189 +0,0 @@
1
- /**
2
- * Transport-agnostic JSON-RPC dispatcher for the app-server protocol.
3
- *
4
- * Pure and testable: no sockets. WS/SSE/stdio adapters wrap this dispatcher
5
- * and are intentionally out of scope here.
6
- *
7
- * Faithful concurrency model (see `protocol.ts`): requests sharing a
8
- * `scopeKey` are serialized FIFO; distinct scope keys run concurrently.
9
- * Because every scope is tenant-prefixed, cross-tenant requests can never
10
- * share a serial lane (fail-closed multi-tenancy by construction).
11
- */
12
-
13
- import {
14
- METHOD_REGISTRY,
15
- type MethodSpec,
16
- NOTIFICATIONS,
17
- type NotificationName,
18
- requestEnvelopeSchema,
19
- resolveScope,
20
- type SerializationScope,
21
- scopeKey,
22
- } from "./protocol";
23
-
24
- // ── Public types ─────────────────────────────────────────────────────────────
25
-
26
- export type MethodHandler = (params: unknown, scope: SerializationScope) => Promise<unknown>;
27
-
28
- export type NotificationListener = (payload: unknown) => void;
29
-
30
- interface JsonRpcSuccess {
31
- readonly jsonrpc: "2.0";
32
- readonly id: string | number | null;
33
- readonly result: unknown;
34
- }
35
-
36
- interface JsonRpcFailure {
37
- readonly jsonrpc: "2.0";
38
- readonly id: string | number | null;
39
- readonly error: { readonly code: number; readonly message: string };
40
- }
41
-
42
- export type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure;
43
-
44
- // ── JSON-RPC error codes (spec-aligned) ──────────────────────────────────────
45
-
46
- const ERR_INVALID_REQUEST = -32600;
47
- const ERR_METHOD_NOT_FOUND = -32601;
48
- const ERR_INVALID_PARAMS = -32602;
49
- const ERR_INTERNAL = -32603;
50
-
51
- function success(id: string | number | null, result: unknown): JsonRpcSuccess {
52
- return { jsonrpc: "2.0", id, result };
53
- }
54
-
55
- function failure(id: string | number | null, code: number, message: string): JsonRpcFailure {
56
- return { jsonrpc: "2.0", id, error: { code, message } };
57
- }
58
-
59
- const NOTIFICATION_NAMES: ReadonlySet<string> = new Set(NOTIFICATIONS);
60
-
61
- const METHOD_SPEC_BY_WIRE: ReadonlyMap<string, MethodSpec> = new Map(
62
- Object.values(METHOD_REGISTRY).map((spec) => [spec.method, spec]),
63
- );
64
-
65
- /** Recover a best-effort id from an unvalidated payload for error responses. */
66
- function extractId(raw: unknown): string | number | null {
67
- if (raw && typeof raw === "object" && "id" in raw) {
68
- const id = (raw as { id: unknown }).id;
69
- if (typeof id === "string" || typeof id === "number") return id;
70
- }
71
- return null;
72
- }
73
-
74
- export class ProtocolDispatcher {
75
- readonly #handlers = new Map<string, MethodHandler>();
76
- readonly #notificationListeners = new Map<NotificationName, Set<NotificationListener>>();
77
- /** Per-scope-key serial promise chains (tail-of-lane). */
78
- readonly #lanes = new Map<string, Promise<void>>();
79
-
80
- /** Register a handler for a wire method name (e.g. `"turn/start"`). */
81
- register(methodName: string, handler: MethodHandler): this {
82
- this.#handlers.set(methodName, handler);
83
- return this;
84
- }
85
-
86
- /** Subscribe to a server→client notification. */
87
- onNotification(name: NotificationName, listener: NotificationListener): () => void {
88
- let set = this.#notificationListeners.get(name);
89
- if (!set) {
90
- set = new Set();
91
- this.#notificationListeners.set(name, set);
92
- }
93
- set.add(listener);
94
- return () => {
95
- set?.delete(listener);
96
- };
97
- }
98
-
99
- /**
100
- * Emit a server→client notification. Validates the name against the
101
- * `NOTIFICATIONS` allow-list (fail-closed: unknown name throws).
102
- */
103
- emitNotification(name: NotificationName, payload: unknown): void {
104
- if (!NOTIFICATION_NAMES.has(name)) {
105
- throw new Error(`Unknown notification name: ${String(name)}`);
106
- }
107
- const listeners = this.#notificationListeners.get(name);
108
- if (!listeners) return;
109
- for (const listener of listeners) listener(payload);
110
- }
111
-
112
- /**
113
- * Dispatch a single raw envelope. Never throws — every failure path maps
114
- * to a JSON-RPC error response.
115
- */
116
- async dispatch(rawEnvelope: unknown): Promise<JsonRpcResponse> {
117
- const parsed = requestEnvelopeSchema.safeParse(rawEnvelope);
118
- if (!parsed.success) {
119
- return failure(
120
- extractId(rawEnvelope),
121
- ERR_INVALID_REQUEST,
122
- "Invalid request envelope (tenantId is mandatory)",
123
- );
124
- }
125
- const envelope = parsed.data;
126
-
127
- const spec = METHOD_SPEC_BY_WIRE.get(envelope.method);
128
- if (!spec) {
129
- return failure(envelope.id, ERR_METHOD_NOT_FOUND, `Method not found: ${envelope.method}`);
130
- }
131
-
132
- const handler = this.#handlers.get(envelope.method);
133
- if (!handler) {
134
- return failure(
135
- envelope.id,
136
- ERR_METHOD_NOT_FOUND,
137
- `No handler registered for method: ${envelope.method}`,
138
- );
139
- }
140
-
141
- const params: { threadId?: string } =
142
- envelope.params && typeof envelope.params === "object"
143
- ? (envelope.params as { threadId?: string })
144
- : {};
145
-
146
- let scope: SerializationScope;
147
- try {
148
- scope = resolveScope(spec, envelope.tenantId, params);
149
- } catch {
150
- return failure(
151
- envelope.id,
152
- ERR_INVALID_PARAMS,
153
- `Method ${envelope.method} requires a threadId for scoping`,
154
- );
155
- }
156
-
157
- const key = scopeKey(scope);
158
- return this.#runSerialized(key, async () => {
159
- try {
160
- const result = await handler(envelope.params, scope);
161
- return success(envelope.id, result);
162
- } catch (error) {
163
- const message = error instanceof Error ? error.message : "Internal handler error";
164
- return failure(envelope.id, ERR_INTERNAL, message);
165
- }
166
- });
167
- }
168
-
169
- /**
170
- * Run `task` after every previously-enqueued task on the same scope key.
171
- * A failing/slow task never poisons or blocks the lane permanently.
172
- */
173
- #runSerialized<T>(key: string, task: () => Promise<T>): Promise<T> {
174
- const previous = this.#lanes.get(key) ?? Promise.resolve();
175
- const run = previous.then(task, task);
176
- // Keep the lane's tail; swallow rejections in the chain link so a thrown
177
- // task does not break FIFO ordering for the next request.
178
- const tail = run.then(
179
- () => undefined,
180
- () => undefined,
181
- );
182
- this.#lanes.set(key, tail);
183
- // Garbage-collect the lane once drained to avoid unbounded key growth.
184
- void tail.then(() => {
185
- if (this.#lanes.get(key) === tail) this.#lanes.delete(key);
186
- });
187
- return run;
188
- }
189
- }
@@ -1,209 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { z } from "zod";
3
- import { createDurableTurn, type DurableTurnQueuePort } from "./durable-turn";
4
- import type { ApprovalGate, ModelInvoker, ModelRoundResult } from "./loop";
5
- import { runTurn } from "./loop";
6
- import type { ThreadEvent, TurnConfig } from "./model";
7
- import type { ReviewDecision } from "./policy";
8
- import { InMemoryRolloutStore, type RolloutStore } from "./rollout";
9
- import { ToolRegistry } from "./tools";
10
-
11
- const config: TurnConfig = {
12
- model: "m",
13
- provider: "p",
14
- approvalPolicy: "on_request",
15
- capabilityPolicy: "external_sandbox",
16
- };
17
-
18
- const approveAll: ApprovalGate = {
19
- async request() {
20
- return { kind: "approved" } as ReviewDecision;
21
- },
22
- };
23
-
24
- /** Model that finishes immediately with text. */
25
- function quickModel(): ModelInvoker {
26
- return {
27
- async invoke(): Promise<ModelRoundResult> {
28
- return { emissions: [{ kind: "text", text: "done" }] };
29
- },
30
- };
31
- }
32
-
33
- function registry(): ToolRegistry {
34
- const reg = new ToolRegistry();
35
- reg.register(
36
- { name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
37
- async (input: { v: string }) => input.v,
38
- );
39
- return reg;
40
- }
41
-
42
- /**
43
- * Fake in-process queue: records enqueues, runs handlers synchronously on
44
- * demand (so a test can simulate "crash" = never drain, then "resume").
45
- */
46
- function fakeQueue(): DurableTurnQueuePort & {
47
- pending: Map<string, () => Promise<void>>;
48
- drain: (jobId: string) => Promise<void>;
49
- } {
50
- const handlers = new Map<string, (payload: unknown) => Promise<void>>();
51
- const pending = new Map<string, () => Promise<void>>();
52
- return {
53
- pending,
54
- async enqueue(job) {
55
- const handler = handlers.get(job.name);
56
- if (!handler) throw new Error(`no handler for ${job.name}`);
57
- pending.set(job.jobId, () => handler(job.payload));
58
- },
59
- registerHandler(name, handler) {
60
- handlers.set(name, handler);
61
- },
62
- async drain(jobId) {
63
- const run = pending.get(jobId);
64
- if (!run) return;
65
- await run();
66
- pending.delete(jobId);
67
- },
68
- };
69
- }
70
-
71
- function runner(model: ModelInvoker, tools: ToolRegistry) {
72
- return (input: string, ctx: { tenantId: string; threadId: string }) =>
73
- runTurn(input, {
74
- tenantId: ctx.tenantId,
75
- threadId: ctx.threadId,
76
- config,
77
- approvalPolicy: { kind: "on_request" },
78
- model,
79
- tools,
80
- store: storeRef,
81
- approvalGate: approveAll,
82
- ruleEvaluator: () => "allow",
83
- });
84
- }
85
-
86
- let storeRef: RolloutStore;
87
-
88
- async function collect(gen: AsyncGenerator<ThreadEvent>): Promise<ThreadEvent[]> {
89
- const out: ThreadEvent[] = [];
90
- for await (const e of gen) out.push(e);
91
- return out;
92
- }
93
-
94
- describe("durable / resumable turn", () => {
95
- it("crash-then-resume continues an unfinished turn", async () => {
96
- const store = new InMemoryRolloutStore();
97
- storeRef = store;
98
- const queue = fakeQueue();
99
- const durable = createDurableTurn({
100
- store,
101
- queue,
102
- runner: runner(quickModel(), registry()),
103
- });
104
-
105
- const { turnId } = await durable.start("hello", {
106
- tenantId: "org_a",
107
- threadId: "th_1",
108
- });
109
- expect(turnId).toBeTruthy();
110
-
111
- // Simulate crash: job enqueued but never drained — no rollout yet.
112
- const before = await store.read("org_a", "th_1");
113
- expect(before.length).toBe(0);
114
-
115
- // Resume re-drives the unfinished turn to completion.
116
- const events = await collect(durable.resume("org_a", "th_1"));
117
- expect(events[events.length - 1]?.type).toBe("turn.completed");
118
-
119
- const lines = await store.read("org_a", "th_1");
120
- expect(lines.length).toBeGreaterThan(0);
121
- expect(lines.every((l) => l.tenantId === "org_a")).toBe(true);
122
- });
123
-
124
- it("resume of a completed turn replays recorded terminal events", async () => {
125
- const store = new InMemoryRolloutStore();
126
- storeRef = store;
127
- const queue = fakeQueue();
128
- const durable = createDurableTurn({
129
- store,
130
- queue,
131
- runner: runner(quickModel(), registry()),
132
- });
133
-
134
- const { turnId } = await durable.start("hello", {
135
- tenantId: "org_a",
136
- threadId: "th_done",
137
- });
138
- await queue.drain(turnId); // job ran to completion (no crash)
139
-
140
- const replayed = await collect(durable.resume("org_a", "th_done"));
141
- expect(replayed.some((e) => e.type === "turn.completed")).toBe(true);
142
-
143
- // Idempotent: a second resume replays the same terminal stream.
144
- const replayedAgain = await collect(durable.resume("org_a", "th_done"));
145
- expect(replayedAgain.map((e) => e.type)).toEqual(replayed.map((e) => e.type));
146
- // Resume must NOT append more lines when already terminal.
147
- const lines = await store.read("org_a", "th_done");
148
- const afterResume = await store.read("org_a", "th_done");
149
- expect(afterResume.length).toBe(lines.length);
150
- });
151
-
152
- it("cross-tenant resume is impossible (isolation)", async () => {
153
- const store = new InMemoryRolloutStore();
154
- storeRef = store;
155
- const queue = fakeQueue();
156
- const durable = createDurableTurn({
157
- store,
158
- queue,
159
- runner: runner(quickModel(), registry()),
160
- });
161
-
162
- const { turnId } = await durable.start("hello", {
163
- tenantId: "org_a",
164
- threadId: "th_secret",
165
- });
166
- await queue.drain(turnId);
167
-
168
- // Another tenant asking for the same threadId sees nothing of org_a's data.
169
- const events = await collect(durable.resume("org_b", "th_secret"));
170
- expect(events.some((e) => e.type === "turn.completed")).toBe(true);
171
- const orgB = await store.read("org_b", "th_secret");
172
- const orgA = await store.read("org_a", "th_secret");
173
- expect(orgB.every((l) => l.tenantId === "org_b")).toBe(true);
174
- expect(orgA.every((l) => l.tenantId === "org_a")).toBe(true);
175
- expect(orgA.length).not.toBe(0);
176
- });
177
-
178
- it("fails closed on missing tenant or thread", async () => {
179
- const store = new InMemoryRolloutStore();
180
- storeRef = store;
181
- const durable = createDurableTurn({
182
- store,
183
- queue: fakeQueue(),
184
- runner: runner(quickModel(), registry()),
185
- });
186
-
187
- await expect(durable.start("x", { tenantId: "", threadId: "th" })).rejects.toThrow();
188
- await expect(durable.start("x", { tenantId: "t", threadId: "" })).rejects.toThrow();
189
- await expect(collect(durable.resume("", "th"))).rejects.toThrow();
190
- await expect(collect(durable.resume("t", ""))).rejects.toThrow();
191
- });
192
-
193
- it("start enqueues a tenant+thread scoped jobId", async () => {
194
- const store = new InMemoryRolloutStore();
195
- storeRef = store;
196
- const queue = fakeQueue();
197
- const durable = createDurableTurn({
198
- store,
199
- queue,
200
- runner: runner(quickModel(), registry()),
201
- });
202
-
203
- const a = await durable.start("x", { tenantId: "org_a", threadId: "th_1" });
204
- const b = await durable.start("x", { tenantId: "org_b", threadId: "th_1" });
205
- expect(a.turnId).not.toBe(b.turnId);
206
- expect([...queue.pending.keys()]).toContain(a.turnId);
207
- expect([...queue.pending.keys()]).toContain(b.turnId);
208
- });
209
- });
@@ -1,135 +0,0 @@
1
- /**
2
- * Durable / resumable turn (WRAP — capability #10).
3
- *
4
- * Wraps the existing {@link runTurn} async generator so a turn survives a
5
- * process crash. `start` enqueues a tenant+thread-scoped durable job whose
6
- * handler drains `runTurn` (the loop runner already appends every event to the
7
- * tenant-scoped {@link RolloutStore}); the durable layer guarantees the job is
8
- * replayable if the worker dies mid-turn. `resume` inspects the persisted
9
- * rollout: if the last recorded event is non-terminal the turn is re-driven to
10
- * completion, otherwise the recorded terminal stream is replayed.
11
- *
12
- * Queue binding is interface-only — callers wire `@nebutra/queue` by
13
- * implementing {@link DurableTurnQueuePort}; this package never depends on it.
14
- * Tenant scoping is mandatory and fail-closed: a missing tenant or thread
15
- * throws, and resume can never read another tenant's rollout.
16
- */
17
-
18
- import { z } from "zod";
19
- import type { ThreadEvent } from "./model";
20
- import { isTurnTerminal } from "./model";
21
- import { type RolloutLine, type RolloutStore, replay } from "./rollout";
22
-
23
- /** A durable job handed to the queue. Mirrors `@nebutra/queue` job shape. */
24
- export interface DurableTurnJob {
25
- /** Logical job name — used for handler routing. */
26
- readonly name: string;
27
- /** Tenant+thread-scoped id; the queue must de-dupe / make idempotent on it. */
28
- readonly jobId: string;
29
- /** Opaque Zod-validated payload (kept narrow on purpose). */
30
- readonly payload: unknown;
31
- }
32
-
33
- /**
34
- * Minimal injectable queue seam (enqueue + handler registration). Mirrors
35
- * `@nebutra/queue`'s `defineQueueJob` / `createJobRouter` contract without a
36
- * hard dependency, so this package stays provider-agnostic.
37
- */
38
- export interface DurableTurnQueuePort {
39
- enqueue(job: DurableTurnJob): Promise<void>;
40
- registerHandler(name: string, handler: (payload: unknown) => Promise<void>): void;
41
- }
42
-
43
- /** Drives one turn, yielding the event stream (persists internally). */
44
- export type DurableTurnRunner = (input: string, ctx: TurnContext) => AsyncGenerator<ThreadEvent>;
45
-
46
- export interface TurnContext {
47
- readonly tenantId: string;
48
- readonly threadId: string;
49
- }
50
-
51
- export interface CreateDurableTurnDeps {
52
- readonly store: RolloutStore;
53
- readonly queue: DurableTurnQueuePort;
54
- readonly runner: DurableTurnRunner;
55
- }
56
-
57
- export interface DurableTurn {
58
- start(input: string, ctx: TurnContext): Promise<{ readonly turnId: string }>;
59
- resume(tenantId: string, threadId: string): AsyncGenerator<ThreadEvent>;
60
- }
61
-
62
- const DURABLE_TURN_JOB = "agent-runtime.durable-turn";
63
-
64
- const contextSchema = z.object({
65
- tenantId: z.string().min(1),
66
- threadId: z.string().min(1),
67
- });
68
-
69
- const jobPayloadSchema = z.object({
70
- tenantId: z.string().min(1),
71
- threadId: z.string().min(1),
72
- input: z.string(),
73
- });
74
-
75
- /** Tenant+thread-scoped, stable job id (idempotency key on the queue). */
76
- function turnIdFor(tenantId: string, threadId: string): string {
77
- return `${DURABLE_TURN_JOB}:${tenantId}:${threadId}`;
78
- }
79
-
80
- /** Recorded `ThreadEvent`s in append order, tenant-scoped via the store read. */
81
- function recordedEvents(lines: readonly RolloutLine[]): ThreadEvent[] {
82
- return lines.flatMap((line) => (line.type === "event" ? [line.event] : []));
83
- }
84
-
85
- export function createDurableTurn(deps: CreateDurableTurnDeps): DurableTurn {
86
- const { store, queue, runner } = deps;
87
-
88
- // Register the durable handler once: drain the runner so every event is
89
- // persisted by the loop runner. Crash before/within = job stays replayable.
90
- queue.registerHandler(DURABLE_TURN_JOB, async (payload: unknown) => {
91
- const { tenantId, threadId, input } = jobPayloadSchema.parse(payload);
92
- const gen = runner(input, { tenantId, threadId });
93
- for await (const _event of gen) {
94
- // The runner persists each event; nothing to do but exhaust it.
95
- }
96
- });
97
-
98
- async function start(input: string, ctx: TurnContext): Promise<{ readonly turnId: string }> {
99
- const { tenantId, threadId } = contextSchema.parse(ctx);
100
- const turnId = turnIdFor(tenantId, threadId);
101
- await queue.enqueue({
102
- name: DURABLE_TURN_JOB,
103
- jobId: turnId,
104
- payload: { tenantId, threadId, input },
105
- });
106
- return { turnId };
107
- }
108
-
109
- async function* resume(tenantId: string, threadId: string): AsyncGenerator<ThreadEvent> {
110
- const scope = contextSchema.parse({ tenantId, threadId });
111
- // Tenant-scoped read — cross-tenant resume is structurally impossible.
112
- const lines = await store.read(scope.tenantId, scope.threadId);
113
- const projection = replay(lines);
114
- const events = recordedEvents(lines);
115
- const last = events[events.length - 1];
116
-
117
- if (projection && last && isTurnTerminal(last)) {
118
- // Completed turn — replay the recorded stream, append nothing.
119
- for (const event of events) yield event;
120
- return;
121
- }
122
-
123
- // Unfinished (crash) or never-started — re-drive to completion. The runner
124
- // appends fresh lines; we recover the original input from the rollout if
125
- // present, else an empty resume prompt (turn is replay-driven thereafter).
126
- const input = "";
127
- const gen = runner(input, {
128
- tenantId: scope.tenantId,
129
- threadId: scope.threadId,
130
- });
131
- for await (const event of gen) yield event;
132
- }
133
-
134
- return { start, resume };
135
- }