@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.
- package/LICENSE +21 -676
- package/README.md +2 -0
- package/dist/adapters/dispatcher-sse.js +1 -0
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/mcp-catalog.js +1 -0
- package/dist/adapters/prisma-rollout.js +1 -0
- package/dist/chunk-424PT5DM.js +23 -0
- package/dist/chunk-424PT5DM.js.map +1 -0
- package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
- package/dist/chunk-4Y25ZTKI.js.map +1 -0
- package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
- package/dist/chunk-D4YAPLOW.js.map +1 -0
- package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
- package/dist/chunk-GQZKYWFT.js.map +1 -0
- package/dist/chunk-KCNN4QUQ.js +255 -0
- package/dist/chunk-KCNN4QUQ.js.map +1 -0
- package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
- package/dist/chunk-NI4EDT4T.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
- package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
- package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +52 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.js +1 -0
- package/dist/definitions.js +1 -0
- package/dist/dispatcher.js +1 -0
- package/dist/durable-turn.js +3 -2
- package/dist/hook-pipeline.js +1 -0
- package/dist/index.d.ts +8 -85
- package/dist/index.js +249 -134
- package/dist/index.js.map +1 -1
- package/dist/loop.d.ts +2 -2
- package/dist/loop.js +3 -2
- package/dist/mcp-bridge.d.ts +3 -3
- package/dist/mcp-bridge.js +3 -2
- package/dist/model.js +1 -0
- package/dist/orchestration.d.ts +84 -0
- package/dist/orchestration.js +16 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/policy.js +1 -0
- package/dist/protocol.js +1 -0
- package/dist/pulsar.d.ts +78 -0
- package/dist/pulsar.js +17 -0
- package/dist/pulsar.js.map +1 -0
- package/dist/rollout-store-persistent.js +1 -0
- package/dist/rollout.js +1 -0
- package/dist/sandbox.js +2 -1
- package/dist/skills.js +2 -1
- package/dist/subagents.js +1 -0
- package/dist/tools.d.ts +4 -3
- package/dist/tools.js +5 -3
- package/package.json +84 -27
- package/.turbo/turbo-build.log +0 -115
- package/.turbo/turbo-test.log +0 -44
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -253
- package/dist/chunk-BJBBR3QA.js.map +0 -1
- package/dist/chunk-NN7DATXA.js.map +0 -1
- package/dist/chunk-PGGWSUTM.js.map +0 -1
- package/dist/chunk-ZMYX5VBU.js.map +0 -1
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -434
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -47
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -76
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -200
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -255
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.ts +0 -126
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -33
- /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
|
-
}
|
package/src/durable-turn.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/durable-turn.ts
DELETED
|
@@ -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
|
-
}
|