@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70
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/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +348 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +762 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +865 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +574 -0
- package/src/scheduled-tasks.ts +596 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4279 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3445 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { SQLiteAdapter } from "./db-adapter.js";
|
|
5
|
+
import {
|
|
6
|
+
SideThreadCoordinator,
|
|
7
|
+
SideThreadError,
|
|
8
|
+
SideThreadStore,
|
|
9
|
+
UnsupportedSideThreadPort,
|
|
10
|
+
type SideThreadActor,
|
|
11
|
+
type SideThreadExecutionPort,
|
|
12
|
+
type SideThreadEventRecord,
|
|
13
|
+
type SideThreadRuntimeEvent,
|
|
14
|
+
} from "./side-thread.js";
|
|
15
|
+
import { handleSideThreadHttpRequest } from "./side-thread-http.js";
|
|
16
|
+
|
|
17
|
+
const contractGolden = JSON.parse(
|
|
18
|
+
readFileSync(
|
|
19
|
+
new URL("../../contracts/side-thread/v1/golden.json", import.meta.url),
|
|
20
|
+
"utf8",
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
const contractSchema = JSON.parse(
|
|
24
|
+
readFileSync(
|
|
25
|
+
new URL("../../contracts/side-thread/v1/schema.json", import.meta.url),
|
|
26
|
+
"utf8",
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
function jsonShape(value: unknown): unknown {
|
|
31
|
+
if (value === null) return null;
|
|
32
|
+
if (Array.isArray(value)) return value.map(jsonShape);
|
|
33
|
+
if (typeof value === "object")
|
|
34
|
+
return Object.fromEntries(
|
|
35
|
+
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
|
36
|
+
key,
|
|
37
|
+
jsonShape(entry),
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
return typeof value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const owner: SideThreadActor = {
|
|
44
|
+
userId: "usr_owner",
|
|
45
|
+
username: "owner",
|
|
46
|
+
tokenId: "tok_owner_1",
|
|
47
|
+
kind: "user",
|
|
48
|
+
};
|
|
49
|
+
const stranger: SideThreadActor = {
|
|
50
|
+
userId: "usr_other",
|
|
51
|
+
username: "other",
|
|
52
|
+
tokenId: "tok_other_1",
|
|
53
|
+
kind: "user",
|
|
54
|
+
};
|
|
55
|
+
const nodeActor: SideThreadActor = {
|
|
56
|
+
userId: "usr_owner",
|
|
57
|
+
username: "owner",
|
|
58
|
+
tokenId: "tok_node_1",
|
|
59
|
+
kind: "node",
|
|
60
|
+
boundNetworkId: "net_a",
|
|
61
|
+
boundNodeId: "node_a",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
class FakePort implements SideThreadExecutionPort {
|
|
65
|
+
listeners = new Set<(event: SideThreadRuntimeEvent) => void>();
|
|
66
|
+
calls = { fork: 0, start: 0, cancel: 0, archive: 0, purge: 0, bringBack: 0 };
|
|
67
|
+
turn = 0;
|
|
68
|
+
onStart?: (
|
|
69
|
+
input: Parameters<SideThreadExecutionPort["start"]>[0],
|
|
70
|
+
turnId: string,
|
|
71
|
+
) => void;
|
|
72
|
+
capability() {
|
|
73
|
+
return {
|
|
74
|
+
supported: true,
|
|
75
|
+
mode: "native-exact-fork" as const,
|
|
76
|
+
runtime: "codex",
|
|
77
|
+
runtimeVersion: "0.148.0",
|
|
78
|
+
topology: "owned-stdio",
|
|
79
|
+
evidenceRevision: "reviewed",
|
|
80
|
+
exactBoundary: { through: true, before: true },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async fork(input: Parameters<SideThreadExecutionPort["fork"]>[0]) {
|
|
84
|
+
this.calls.fork++;
|
|
85
|
+
return { threadId: `derived_${input.sideChatId}` };
|
|
86
|
+
}
|
|
87
|
+
async start(input: Parameters<SideThreadExecutionPort["start"]>[0]) {
|
|
88
|
+
this.calls.start++;
|
|
89
|
+
const turnId = `turn_${++this.turn}`;
|
|
90
|
+
this.onStart?.(input, turnId);
|
|
91
|
+
return { turnId };
|
|
92
|
+
}
|
|
93
|
+
async cancel() {
|
|
94
|
+
this.calls.cancel++;
|
|
95
|
+
}
|
|
96
|
+
async archive() {
|
|
97
|
+
this.calls.archive++;
|
|
98
|
+
}
|
|
99
|
+
async purge() {
|
|
100
|
+
this.calls.purge++;
|
|
101
|
+
}
|
|
102
|
+
async bringBack() {
|
|
103
|
+
this.calls.bringBack++;
|
|
104
|
+
return { destinationTurnId: `destination_${this.calls.bringBack}` };
|
|
105
|
+
}
|
|
106
|
+
subscribe(listener: (event: SideThreadRuntimeEvent) => void) {
|
|
107
|
+
this.listeners.add(listener);
|
|
108
|
+
return () => this.listeners.delete(listener);
|
|
109
|
+
}
|
|
110
|
+
emit(event: SideThreadRuntimeEvent) {
|
|
111
|
+
for (const listener of this.listeners) listener(event);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const closers: Array<() => void> = [];
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
while (closers.length) closers.pop()!();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function fixture(
|
|
121
|
+
options: {
|
|
122
|
+
enabled?: boolean;
|
|
123
|
+
port?: SideThreadExecutionPort;
|
|
124
|
+
attachment?: (fileId: string) => boolean;
|
|
125
|
+
} = {},
|
|
126
|
+
) {
|
|
127
|
+
const raw = new Database(":memory:");
|
|
128
|
+
const db = new SQLiteAdapter(raw);
|
|
129
|
+
const store = new SideThreadStore(db);
|
|
130
|
+
const port = options.port ?? new FakePort();
|
|
131
|
+
let serial = 0;
|
|
132
|
+
const coordinator = new SideThreadCoordinator(store, port, {
|
|
133
|
+
enabled: options.enabled ?? true,
|
|
134
|
+
authorizeNode: (actor, networkId, nodeId) =>
|
|
135
|
+
actor.userId === owner.userId &&
|
|
136
|
+
networkId === "net_a" &&
|
|
137
|
+
nodeId === "node_a" &&
|
|
138
|
+
(actor.kind !== "node" || actor.boundNodeId === nodeId),
|
|
139
|
+
authorizeAttachment: (_actor, networkId, ref) =>
|
|
140
|
+
networkId === "net_a" && (options.attachment?.(ref.fileId) ?? true),
|
|
141
|
+
now: () => 1_700_000_000_000 + serial,
|
|
142
|
+
id: () => `id${++serial}`,
|
|
143
|
+
});
|
|
144
|
+
closers.push(
|
|
145
|
+
() => coordinator.close(),
|
|
146
|
+
() => db.close(),
|
|
147
|
+
);
|
|
148
|
+
return { db, store, port: port as FakePort, coordinator };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createInput(overrides: Record<string, unknown> = {}) {
|
|
152
|
+
return {
|
|
153
|
+
requestKey: "create-key-0001",
|
|
154
|
+
networkId: "net_a",
|
|
155
|
+
nodeId: "node_a",
|
|
156
|
+
sourceThreadId: "source_thread",
|
|
157
|
+
boundary: { kind: "through" as const, turnId: "source_turn_5" },
|
|
158
|
+
prompt: "原问题第一行\n完整问题必须跨窗口保留",
|
|
159
|
+
attachments: [{ fileId: "file_ref_0001" }],
|
|
160
|
+
...overrides,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe("SideThread Hub contract", () => {
|
|
165
|
+
test("feature flag and missing runtime adapter fail closed without a FIFO fallback", async () => {
|
|
166
|
+
const disabled = fixture({ enabled: false });
|
|
167
|
+
await expect(
|
|
168
|
+
disabled.coordinator.create(owner, createInput()),
|
|
169
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_DISABLED", status: 404 });
|
|
170
|
+
expect(disabled.port.calls.fork).toBe(0);
|
|
171
|
+
|
|
172
|
+
const unsupported = fixture({ port: new UnsupportedSideThreadPort() });
|
|
173
|
+
await expect(
|
|
174
|
+
unsupported.coordinator.create(owner, createInput()),
|
|
175
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_UNSUPPORTED", status: 501 });
|
|
176
|
+
expect(
|
|
177
|
+
unsupported.db.get<{ n: number }>("SELECT COUNT(*) n FROM side_chats")?.n,
|
|
178
|
+
).toBe(0);
|
|
179
|
+
|
|
180
|
+
const unverified = new FakePort();
|
|
181
|
+
unverified.capability = () => ({
|
|
182
|
+
supported: true,
|
|
183
|
+
runtime: "codex",
|
|
184
|
+
runtimeVersion: "unknown",
|
|
185
|
+
topology: "shared",
|
|
186
|
+
evidenceRevision: "unreviewed",
|
|
187
|
+
exactBoundary: { through: true, before: true },
|
|
188
|
+
});
|
|
189
|
+
const unverifiedFixture = fixture({ port: unverified });
|
|
190
|
+
await expect(
|
|
191
|
+
unverifiedFixture.coordinator.create(owner, createInput()),
|
|
192
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_UNSUPPORTED", status: 501 });
|
|
193
|
+
expect(unverified.calls.fork).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("create is payload-bound idempotent and stores exact four-part ownership", async () => {
|
|
197
|
+
const f = fixture();
|
|
198
|
+
const first = await f.coordinator.create(owner, createInput());
|
|
199
|
+
const replay = await f.coordinator.create(owner, createInput());
|
|
200
|
+
expect(replay.sideChatId).toBe(first.sideChatId);
|
|
201
|
+
expect(f.port.calls).toMatchObject({ fork: 1, start: 1 });
|
|
202
|
+
expect(first.state).toBe("running");
|
|
203
|
+
expect(first.attempts[0]).toMatchObject({
|
|
204
|
+
attemptId: first.activeAttemptId,
|
|
205
|
+
threadId: first.threadId,
|
|
206
|
+
turnId: "turn_1",
|
|
207
|
+
state: "running",
|
|
208
|
+
});
|
|
209
|
+
await expect(
|
|
210
|
+
f.coordinator.create(owner, createInput({ prompt: "different" })),
|
|
211
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_CONFLICT" });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("cross-window hydration returns the complete owner question and cross-owner reads are opaque", async () => {
|
|
215
|
+
const f = fixture();
|
|
216
|
+
const created = await f.coordinator.create(owner, createInput());
|
|
217
|
+
f.coordinator.close();
|
|
218
|
+
const reopened = new SideThreadCoordinator(
|
|
219
|
+
new SideThreadStore(f.db),
|
|
220
|
+
f.port,
|
|
221
|
+
{
|
|
222
|
+
enabled: true,
|
|
223
|
+
authorizeNode: (actor, networkId, nodeId) =>
|
|
224
|
+
actor.userId === owner.userId &&
|
|
225
|
+
networkId === "net_a" &&
|
|
226
|
+
nodeId === "node_a",
|
|
227
|
+
authorizeAttachment: () => true,
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
closers.push(() => reopened.close());
|
|
231
|
+
expect(reopened.get(owner, created.sideChatId).question).toBe(
|
|
232
|
+
createInput().prompt,
|
|
233
|
+
);
|
|
234
|
+
expect(() => reopened.get(stranger, created.sideChatId)).toThrow(
|
|
235
|
+
new SideThreadError("SIDE_THREAD_NOT_FOUND", "side chat not found", 404),
|
|
236
|
+
);
|
|
237
|
+
expect(() =>
|
|
238
|
+
reopened.get({ ...nodeActor, boundNodeId: "node_b" }, created.sideChatId),
|
|
239
|
+
).toThrow();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("attachments accept authorized fileId references only", async () => {
|
|
243
|
+
const f = fixture({ attachment: (id) => id === "file_ref_0001" });
|
|
244
|
+
await f.coordinator.create(owner, createInput());
|
|
245
|
+
await expect(
|
|
246
|
+
f.coordinator.create(
|
|
247
|
+
owner,
|
|
248
|
+
createInput({
|
|
249
|
+
requestKey: "create-key-0002",
|
|
250
|
+
attachments: [{ fileId: "file_missing_01" }],
|
|
251
|
+
}),
|
|
252
|
+
),
|
|
253
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_ATTACHMENT_NOT_FOUND" });
|
|
254
|
+
await expect(
|
|
255
|
+
f.coordinator.create(
|
|
256
|
+
owner,
|
|
257
|
+
createInput({
|
|
258
|
+
requestKey: "create-key-0003",
|
|
259
|
+
attachments: [{ fileId: "file_ref_0001", path: "/etc/passwd" }],
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_CONFLICT" });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("terminal events require sideChatId + attemptId + threadId + turnId", async () => {
|
|
266
|
+
const f = fixture();
|
|
267
|
+
const r = await f.coordinator.create(owner, createInput());
|
|
268
|
+
const a = r.attempts[0];
|
|
269
|
+
f.port.emit({
|
|
270
|
+
sideChatId: r.sideChatId,
|
|
271
|
+
attemptId: a.attemptId,
|
|
272
|
+
threadId: r.sourceThreadId,
|
|
273
|
+
turnId: a.turnId!,
|
|
274
|
+
status: "completed",
|
|
275
|
+
text: "wrong source",
|
|
276
|
+
});
|
|
277
|
+
expect(f.coordinator.get(owner, r.sideChatId).state).toBe("running");
|
|
278
|
+
f.port.emit({
|
|
279
|
+
sideChatId: r.sideChatId,
|
|
280
|
+
attemptId: a.attemptId,
|
|
281
|
+
threadId: r.threadId!,
|
|
282
|
+
turnId: "turn_sibling",
|
|
283
|
+
status: "completed",
|
|
284
|
+
text: "wrong turn",
|
|
285
|
+
});
|
|
286
|
+
expect(f.coordinator.get(owner, r.sideChatId).state).toBe("running");
|
|
287
|
+
f.port.emit({
|
|
288
|
+
sideChatId: r.sideChatId,
|
|
289
|
+
attemptId: a.attemptId,
|
|
290
|
+
threadId: r.threadId!,
|
|
291
|
+
turnId: a.turnId!,
|
|
292
|
+
status: "completed",
|
|
293
|
+
text: "answer",
|
|
294
|
+
});
|
|
295
|
+
expect(f.coordinator.get(owner, r.sideChatId)).toMatchObject({
|
|
296
|
+
state: "completed",
|
|
297
|
+
activeAttemptId: undefined,
|
|
298
|
+
attempts: [{ state: "completed", result: "answer" }],
|
|
299
|
+
});
|
|
300
|
+
f.port.emit({
|
|
301
|
+
sideChatId: r.sideChatId,
|
|
302
|
+
attemptId: a.attemptId,
|
|
303
|
+
threadId: r.threadId!,
|
|
304
|
+
turnId: a.turnId!,
|
|
305
|
+
status: "failed",
|
|
306
|
+
error: "late",
|
|
307
|
+
});
|
|
308
|
+
expect(f.coordinator.get(owner, r.sideChatId).state).toBe("completed");
|
|
309
|
+
expect(
|
|
310
|
+
f.coordinator
|
|
311
|
+
.listEvents(owner, r.sideChatId)
|
|
312
|
+
.filter((e) => e.type === "runtime_event.dropped"),
|
|
313
|
+
).toHaveLength(3);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("throwing observers cannot alter durable truth or starve cursor replay", async () => {
|
|
317
|
+
const f = fixture();
|
|
318
|
+
const record = await f.coordinator.create(owner, createInput());
|
|
319
|
+
const attempt = record.attempts[0];
|
|
320
|
+
const cursor = f.coordinator.listEvents(owner, record.sideChatId).at(-1)!.eventId;
|
|
321
|
+
const delivered: SideThreadEventRecord[] = [];
|
|
322
|
+
const offThrow = f.coordinator.subscribe(record.sideChatId, () => { throw new Error("observer exploded"); });
|
|
323
|
+
const offGood = f.coordinator.subscribe(record.sideChatId, (event) => delivered.push(event));
|
|
324
|
+
f.port.emit({ sideChatId: record.sideChatId, attemptId: attempt.attemptId, threadId: record.threadId!, turnId: attempt.turnId!, status: "completed", text: "durable answer" });
|
|
325
|
+
await Bun.sleep(0);
|
|
326
|
+
expect(f.coordinator.get(owner, record.sideChatId)).toMatchObject({ state: "completed", attempts: [{ result: "durable answer" }] });
|
|
327
|
+
expect(delivered.filter((event) => event.type === "attempt.terminal")).toHaveLength(1);
|
|
328
|
+
const replay = f.coordinator.listEvents(owner, record.sideChatId, cursor);
|
|
329
|
+
expect(replay.filter((event) => event.type === "attempt.terminal")).toHaveLength(1);
|
|
330
|
+
expect(f.coordinator.listEvents(owner, record.sideChatId, replay.at(-1)!.eventId)).toHaveLength(0);
|
|
331
|
+
offThrow(); offGood();
|
|
332
|
+
f.coordinator.close();
|
|
333
|
+
const reopened = new SideThreadCoordinator(f.store, f.port, { enabled: true, authorizeNode: () => true, authorizeAttachment: () => true });
|
|
334
|
+
closers.push(() => reopened.close());
|
|
335
|
+
expect(reopened.listEvents(owner, record.sideChatId, cursor).filter((event) => event.type === "attempt.terminal")).toHaveLength(1);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("a terminal event arriving before start returns settles once", async () => {
|
|
339
|
+
const f = fixture();
|
|
340
|
+
f.port.onStart = (input, turnId) =>
|
|
341
|
+
f.port.emit({
|
|
342
|
+
sideChatId: input.sideChatId,
|
|
343
|
+
attemptId: input.attemptId,
|
|
344
|
+
threadId: input.threadId,
|
|
345
|
+
turnId,
|
|
346
|
+
status: "completed",
|
|
347
|
+
text: "fast",
|
|
348
|
+
});
|
|
349
|
+
const r = await f.coordinator.create(owner, createInput());
|
|
350
|
+
expect(r).toMatchObject({
|
|
351
|
+
state: "completed",
|
|
352
|
+
activeAttemptId: undefined,
|
|
353
|
+
attempts: [{ state: "completed", turnId: "turn_1", result: "fast" }],
|
|
354
|
+
});
|
|
355
|
+
expect(
|
|
356
|
+
f.coordinator
|
|
357
|
+
.listEvents(owner, r.sideChatId)
|
|
358
|
+
.filter((e) => e.type === "attempt.terminal"),
|
|
359
|
+
).toHaveLength(1);
|
|
360
|
+
expect(
|
|
361
|
+
f.coordinator
|
|
362
|
+
.listEvents(owner, r.sideChatId)
|
|
363
|
+
.filter((e) => e.type === "attempt.running"),
|
|
364
|
+
).toHaveLength(0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("cancel is exact and cannot regress a terminal race", async () => {
|
|
368
|
+
const f = fixture();
|
|
369
|
+
const r = await f.coordinator.create(owner, createInput());
|
|
370
|
+
const a = r.attempts[0];
|
|
371
|
+
f.port.cancel = async () => {
|
|
372
|
+
f.port.calls.cancel++;
|
|
373
|
+
f.port.emit({
|
|
374
|
+
sideChatId: r.sideChatId,
|
|
375
|
+
attemptId: a.attemptId,
|
|
376
|
+
threadId: r.threadId!,
|
|
377
|
+
turnId: a.turnId!,
|
|
378
|
+
status: "completed",
|
|
379
|
+
text: "won race",
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
const after = await f.coordinator.cancel(owner, r.sideChatId, {
|
|
383
|
+
requestKey: "cancel-key-race1",
|
|
384
|
+
});
|
|
385
|
+
expect(after.state).toBe("completed");
|
|
386
|
+
expect(after.attempts[0].result).toBe("won race");
|
|
387
|
+
expect(
|
|
388
|
+
f.coordinator
|
|
389
|
+
.listEvents(owner, r.sideChatId)
|
|
390
|
+
.filter((e) => e.type === "attempt.cancelled"),
|
|
391
|
+
).toHaveLength(0);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("cross-coordinator create and cancel claims execute the runtime once", async () => {
|
|
395
|
+
const f = fixture();
|
|
396
|
+
let forkEntered!: () => void;
|
|
397
|
+
const entered = new Promise<void>((resolve) => (forkEntered = resolve));
|
|
398
|
+
let releaseFork!: () => void;
|
|
399
|
+
const forkGate = new Promise<void>((resolve) => (releaseFork = resolve));
|
|
400
|
+
f.port.fork = async (input) => {
|
|
401
|
+
f.port.calls.fork++;
|
|
402
|
+
forkEntered();
|
|
403
|
+
await forkGate;
|
|
404
|
+
return { threadId: `derived_${input.sideChatId}` };
|
|
405
|
+
};
|
|
406
|
+
const second = new SideThreadCoordinator(f.store, f.port, {
|
|
407
|
+
enabled: true,
|
|
408
|
+
authorizeNode: (actor, networkId, nodeId) =>
|
|
409
|
+
actor.userId === owner.userId &&
|
|
410
|
+
networkId === "net_a" &&
|
|
411
|
+
nodeId === "node_a",
|
|
412
|
+
authorizeAttachment: () => true,
|
|
413
|
+
});
|
|
414
|
+
closers.push(() => second.close());
|
|
415
|
+
const firstCreate = f.coordinator.create(owner, createInput());
|
|
416
|
+
await entered;
|
|
417
|
+
releaseFork();
|
|
418
|
+
const created = await firstCreate;
|
|
419
|
+
const replay = await second.create(owner, createInput());
|
|
420
|
+
expect(replay.state).toBe("running");
|
|
421
|
+
expect(f.port.calls.fork).toBe(1);
|
|
422
|
+
|
|
423
|
+
let cancelEntered!: () => void;
|
|
424
|
+
const cancelStarted = new Promise<void>(
|
|
425
|
+
(resolve) => (cancelEntered = resolve),
|
|
426
|
+
);
|
|
427
|
+
let releaseCancel!: () => void;
|
|
428
|
+
const cancelGate = new Promise<void>(
|
|
429
|
+
(resolve) => (releaseCancel = resolve),
|
|
430
|
+
);
|
|
431
|
+
f.port.cancel = async () => {
|
|
432
|
+
f.port.calls.cancel++;
|
|
433
|
+
cancelEntered();
|
|
434
|
+
await cancelGate;
|
|
435
|
+
};
|
|
436
|
+
const firstCancel = f.coordinator.cancel(owner, created.sideChatId, {
|
|
437
|
+
requestKey: "cancel-key-cross1",
|
|
438
|
+
});
|
|
439
|
+
await cancelStarted;
|
|
440
|
+
const concurrent = await second.cancel(owner, created.sideChatId, {
|
|
441
|
+
requestKey: "cancel-key-cross1",
|
|
442
|
+
});
|
|
443
|
+
expect(concurrent.state).toBe("running");
|
|
444
|
+
expect(f.port.calls.cancel).toBe(1);
|
|
445
|
+
releaseCancel();
|
|
446
|
+
expect((await firstCancel).state).toBe("running");
|
|
447
|
+
const cancelledAttempt = created.attempts[0];
|
|
448
|
+
f.port.emit({ sideChatId: created.sideChatId, attemptId: cancelledAttempt.attemptId, threadId: created.threadId!, turnId: cancelledAttempt.turnId!, status: "interrupted" });
|
|
449
|
+
|
|
450
|
+
let archiveEntered!: () => void;
|
|
451
|
+
const archiveStarted = new Promise<void>(
|
|
452
|
+
(resolve) => (archiveEntered = resolve),
|
|
453
|
+
);
|
|
454
|
+
let releaseArchive!: () => void;
|
|
455
|
+
const archiveGate = new Promise<void>(
|
|
456
|
+
(resolve) => (releaseArchive = resolve),
|
|
457
|
+
);
|
|
458
|
+
f.port.archive = async () => {
|
|
459
|
+
f.port.calls.archive++;
|
|
460
|
+
archiveEntered();
|
|
461
|
+
await archiveGate;
|
|
462
|
+
};
|
|
463
|
+
const firstArchive = f.coordinator.archive(owner, created.sideChatId, {
|
|
464
|
+
requestKey: "archive-key-cross1",
|
|
465
|
+
});
|
|
466
|
+
await archiveStarted;
|
|
467
|
+
await expect(
|
|
468
|
+
second.archive(owner, created.sideChatId, {
|
|
469
|
+
requestKey: "archive-key-cross1",
|
|
470
|
+
}),
|
|
471
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_CONFLICT" });
|
|
472
|
+
expect(f.port.calls.archive).toBe(1);
|
|
473
|
+
releaseArchive();
|
|
474
|
+
expect((await firstArchive).state).toBe("archived");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("retry persists parent attempt lineage and blocks concurrent active retries", async () => {
|
|
478
|
+
const f = fixture();
|
|
479
|
+
const r = await f.coordinator.create(owner, createInput());
|
|
480
|
+
const first = r.attempts[0];
|
|
481
|
+
f.port.emit({
|
|
482
|
+
sideChatId: r.sideChatId,
|
|
483
|
+
attemptId: first.attemptId,
|
|
484
|
+
threadId: r.threadId!,
|
|
485
|
+
turnId: first.turnId!,
|
|
486
|
+
status: "failed",
|
|
487
|
+
error: "boom",
|
|
488
|
+
});
|
|
489
|
+
const retried = await f.coordinator.retry(owner, r.sideChatId, {
|
|
490
|
+
requestKey: "retry-key-0001",
|
|
491
|
+
prompt: "try again",
|
|
492
|
+
});
|
|
493
|
+
expect(retried.attempts[1]).toMatchObject({
|
|
494
|
+
parentAttemptId: first.attemptId,
|
|
495
|
+
state: "running",
|
|
496
|
+
threadId: r.threadId,
|
|
497
|
+
turnId: "turn_2",
|
|
498
|
+
});
|
|
499
|
+
await expect(
|
|
500
|
+
f.coordinator.retry(owner, r.sideChatId, {
|
|
501
|
+
requestKey: "retry-key-0002",
|
|
502
|
+
prompt: "parallel",
|
|
503
|
+
}),
|
|
504
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_CONFLICT" });
|
|
505
|
+
const replay = await f.coordinator.retry(owner, r.sideChatId, {
|
|
506
|
+
requestKey: "retry-key-0001",
|
|
507
|
+
prompt: "try again",
|
|
508
|
+
});
|
|
509
|
+
expect(replay.attempts).toHaveLength(2);
|
|
510
|
+
expect(f.port.calls.start).toBe(2);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("archive/purge are terminal-safe and idempotent", async () => {
|
|
514
|
+
const f = fixture();
|
|
515
|
+
const r = await f.coordinator.create(owner, createInput());
|
|
516
|
+
const a = r.attempts[0];
|
|
517
|
+
await expect(
|
|
518
|
+
f.coordinator.archive(owner, r.sideChatId, {
|
|
519
|
+
requestKey: "archive-key-running1",
|
|
520
|
+
}),
|
|
521
|
+
).rejects.toMatchObject({
|
|
522
|
+
code: "SIDE_THREAD_CONFLICT",
|
|
523
|
+
});
|
|
524
|
+
f.port.emit({
|
|
525
|
+
sideChatId: r.sideChatId,
|
|
526
|
+
attemptId: a.attemptId,
|
|
527
|
+
threadId: r.threadId!,
|
|
528
|
+
turnId: a.turnId!,
|
|
529
|
+
status: "completed",
|
|
530
|
+
text: "done",
|
|
531
|
+
});
|
|
532
|
+
expect(
|
|
533
|
+
(
|
|
534
|
+
await f.coordinator.archive(owner, r.sideChatId, {
|
|
535
|
+
requestKey: "archive-key-0001",
|
|
536
|
+
})
|
|
537
|
+
).state,
|
|
538
|
+
).toBe("archived");
|
|
539
|
+
expect(
|
|
540
|
+
(
|
|
541
|
+
await f.coordinator.archive(owner, r.sideChatId, {
|
|
542
|
+
requestKey: "archive-key-0001",
|
|
543
|
+
})
|
|
544
|
+
).state,
|
|
545
|
+
).toBe("archived");
|
|
546
|
+
// A failed receipt may contain a scrubbed runtime reason from an earlier
|
|
547
|
+
// process/version. Purge must remove it even though it is not projected by
|
|
548
|
+
// the public record.
|
|
549
|
+
f.db.run(
|
|
550
|
+
"INSERT INTO side_chat_bring_backs (bring_back_id,side_chat_id,attempt_id,owner_user_id,request_key,request_fingerprint,destination_thread_id,state,error_text,created_at,updated_at) VALUES ('sbb_failed',?1,?2,?3,'bring-failed-0001','fingerprint','source_thread','failed','private runtime detail',1,1)",
|
|
551
|
+
[r.sideChatId, a.attemptId, owner.userId],
|
|
552
|
+
);
|
|
553
|
+
expect(
|
|
554
|
+
(
|
|
555
|
+
await f.coordinator.purge(owner, r.sideChatId, {
|
|
556
|
+
requestKey: "purge-key-0001",
|
|
557
|
+
})
|
|
558
|
+
).state,
|
|
559
|
+
).toBe("purged");
|
|
560
|
+
const purged = f.coordinator.get(owner, r.sideChatId);
|
|
561
|
+
expect(purged.question).toBe("");
|
|
562
|
+
expect(purged.attachments).toEqual([]);
|
|
563
|
+
expect(purged.attempts[0]).toMatchObject({
|
|
564
|
+
result: undefined,
|
|
565
|
+
error: undefined,
|
|
566
|
+
attachments: [],
|
|
567
|
+
});
|
|
568
|
+
expect(
|
|
569
|
+
f.db.get<{
|
|
570
|
+
result_text: string | null;
|
|
571
|
+
error_text: string | null;
|
|
572
|
+
attachments_json: string;
|
|
573
|
+
}>(
|
|
574
|
+
"SELECT result_text,error_text,attachments_json FROM side_chat_attempts WHERE attempt_id=?1",
|
|
575
|
+
a.attemptId,
|
|
576
|
+
),
|
|
577
|
+
).toEqual({ result_text: null, error_text: null, attachments_json: "[]" });
|
|
578
|
+
expect(
|
|
579
|
+
f.db.get<{ error_text: string | null }>(
|
|
580
|
+
"SELECT error_text FROM side_chat_bring_backs WHERE bring_back_id='sbb_failed'",
|
|
581
|
+
),
|
|
582
|
+
).toEqual({ error_text: null });
|
|
583
|
+
expect(f.port.calls).toMatchObject({ archive: 1, purge: 1 });
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("bring-back is completed-attempt-only and payload-bound idempotent", async () => {
|
|
587
|
+
const f = fixture();
|
|
588
|
+
const r = await f.coordinator.create(owner, createInput());
|
|
589
|
+
const a = r.attempts[0];
|
|
590
|
+
await expect(
|
|
591
|
+
f.coordinator.bringBack(owner, r.sideChatId, {
|
|
592
|
+
requestKey: "bring-key-0001",
|
|
593
|
+
destinationThreadId: "source_thread",
|
|
594
|
+
}),
|
|
595
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_CONFLICT" });
|
|
596
|
+
f.port.emit({
|
|
597
|
+
sideChatId: r.sideChatId,
|
|
598
|
+
attemptId: a.attemptId,
|
|
599
|
+
threadId: r.threadId!,
|
|
600
|
+
turnId: a.turnId!,
|
|
601
|
+
status: "completed",
|
|
602
|
+
text: "answer",
|
|
603
|
+
});
|
|
604
|
+
const one = await f.coordinator.bringBack(owner, r.sideChatId, {
|
|
605
|
+
requestKey: "bring-key-0001",
|
|
606
|
+
destinationThreadId: "source_thread",
|
|
607
|
+
});
|
|
608
|
+
const replay = await f.coordinator.bringBack(owner, r.sideChatId, {
|
|
609
|
+
requestKey: "bring-key-0001",
|
|
610
|
+
destinationThreadId: "source_thread",
|
|
611
|
+
});
|
|
612
|
+
expect(replay).toEqual(one);
|
|
613
|
+
expect(f.port.calls.bringBack).toBe(1);
|
|
614
|
+
const differentRequest = await f.coordinator.bringBack(
|
|
615
|
+
owner,
|
|
616
|
+
r.sideChatId,
|
|
617
|
+
{
|
|
618
|
+
requestKey: "bring-key-0002",
|
|
619
|
+
destinationThreadId: "source_thread",
|
|
620
|
+
},
|
|
621
|
+
);
|
|
622
|
+
expect(differentRequest).toEqual(one);
|
|
623
|
+
expect(f.port.calls.bringBack).toBe(1);
|
|
624
|
+
await expect(
|
|
625
|
+
f.coordinator.bringBack(owner, r.sideChatId, {
|
|
626
|
+
requestKey: "bring-key-0003",
|
|
627
|
+
destinationThreadId: "other_thread",
|
|
628
|
+
}),
|
|
629
|
+
).rejects.toMatchObject({ code: "SIDE_THREAD_CONFLICT" });
|
|
630
|
+
|
|
631
|
+
f.coordinator.close();
|
|
632
|
+
const reopened = new SideThreadCoordinator(f.store, f.port, {
|
|
633
|
+
enabled: true,
|
|
634
|
+
authorizeNode: (actor, networkId, nodeId) =>
|
|
635
|
+
actor.userId === owner.userId &&
|
|
636
|
+
networkId === "net_a" &&
|
|
637
|
+
nodeId === "node_a",
|
|
638
|
+
authorizeAttachment: () => true,
|
|
639
|
+
});
|
|
640
|
+
closers.push(() => reopened.close());
|
|
641
|
+
const hydrated = reopened.get(owner, r.sideChatId);
|
|
642
|
+
expect(hydrated.bringBacks).toHaveLength(1);
|
|
643
|
+
expect(hydrated.bringBacks[0]).toMatchObject({
|
|
644
|
+
attemptId: a.attemptId,
|
|
645
|
+
destinationThreadId: "source_thread",
|
|
646
|
+
destinationTurnId: one.destinationTurnId,
|
|
647
|
+
requestKey: "bring-key-0001",
|
|
648
|
+
state: "completed",
|
|
649
|
+
});
|
|
650
|
+
expect(hydrated.bringBacks[0].completedAt).toBeNumber();
|
|
651
|
+
expect(reopened.list(owner)).toHaveLength(1);
|
|
652
|
+
expect(reopened.list(stranger)).toHaveLength(0);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("response loss remains ambiguous under the same stable start operation", async () => {
|
|
656
|
+
const port = new FakePort();
|
|
657
|
+
let accepted: Parameters<SideThreadExecutionPort["start"]>[0] | undefined;
|
|
658
|
+
port.start = async (input) => {
|
|
659
|
+
port.calls.start++;
|
|
660
|
+
accepted = input;
|
|
661
|
+
throw Object.assign(new Error("secret transport detail"), {
|
|
662
|
+
code: "SIDE_THREAD_RESPONSE_LOST",
|
|
663
|
+
});
|
|
664
|
+
};
|
|
665
|
+
const f = fixture({ port });
|
|
666
|
+
await expect(
|
|
667
|
+
f.coordinator.create(owner, createInput()),
|
|
668
|
+
).rejects.toMatchObject({
|
|
669
|
+
code: "SIDE_THREAD_AMBIGUOUS",
|
|
670
|
+
operationId: expect.stringMatching(/^sop_/),
|
|
671
|
+
});
|
|
672
|
+
const ambiguous = f.store.getByCreateKey(owner, "create-key-0001")!.record;
|
|
673
|
+
const startOperation = ambiguous.operations.find(
|
|
674
|
+
(operation) => operation.kind === "start",
|
|
675
|
+
)!;
|
|
676
|
+
expect(ambiguous).toMatchObject({
|
|
677
|
+
requestKey: "create-key-0001",
|
|
678
|
+
state: "ambiguous",
|
|
679
|
+
attempts: [{ state: "ambiguous" }],
|
|
680
|
+
});
|
|
681
|
+
expect(startOperation).toMatchObject({
|
|
682
|
+
operationId: accepted!.operationId,
|
|
683
|
+
state: "ambiguous",
|
|
684
|
+
errorCode: "SIDE_THREAD_RESPONSE_LOST",
|
|
685
|
+
});
|
|
686
|
+
await expect(f.coordinator.create(owner, createInput())).rejects.toMatchObject({
|
|
687
|
+
code: "SIDE_THREAD_AMBIGUOUS",
|
|
688
|
+
sideChatId: ambiguous.sideChatId,
|
|
689
|
+
});
|
|
690
|
+
expect(port.calls).toMatchObject({ fork: 1, start: 2 });
|
|
691
|
+
|
|
692
|
+
f.coordinator.close();
|
|
693
|
+
const reopened = new SideThreadCoordinator(f.store, port, {
|
|
694
|
+
enabled: true,
|
|
695
|
+
authorizeNode: (actor, networkId, nodeId) =>
|
|
696
|
+
actor.userId === owner.userId &&
|
|
697
|
+
networkId === "net_a" &&
|
|
698
|
+
nodeId === "node_a",
|
|
699
|
+
});
|
|
700
|
+
closers.push(() => reopened.close());
|
|
701
|
+
port.emit({
|
|
702
|
+
sideChatId: ambiguous.sideChatId,
|
|
703
|
+
attemptId: accepted!.attemptId,
|
|
704
|
+
threadId: accepted!.threadId,
|
|
705
|
+
turnId: "turn_authoritative",
|
|
706
|
+
status: "completed",
|
|
707
|
+
text: "authoritative answer",
|
|
708
|
+
});
|
|
709
|
+
const reconciled = reopened.get(owner, ambiguous.sideChatId);
|
|
710
|
+
expect(reconciled).toMatchObject({
|
|
711
|
+
state: "completed",
|
|
712
|
+
attempts: [{ state: "completed", result: "authoritative answer" }],
|
|
713
|
+
});
|
|
714
|
+
expect(
|
|
715
|
+
reconciled.operations.find((operation) => operation.kind === "start"),
|
|
716
|
+
).toMatchObject({ state: "completed", turnId: "turn_authoritative" });
|
|
717
|
+
expect(port.calls.start).toBe(2);
|
|
718
|
+
expect(
|
|
719
|
+
JSON.stringify(reopened.listEvents(owner, ambiguous.sideChatId)),
|
|
720
|
+
).not.toContain("secret transport detail");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("ambiguous cancel/archive/purge replay the same stable operation identity", async () => {
|
|
724
|
+
const lose = () =>
|
|
725
|
+
Object.assign(new Error("accepted but ACK lost"), {
|
|
726
|
+
code: "SIDE_THREAD_RESPONSE_LOST",
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const cancelPort = new FakePort();
|
|
730
|
+
cancelPort.cancel = async () => {
|
|
731
|
+
cancelPort.calls.cancel++;
|
|
732
|
+
throw lose();
|
|
733
|
+
};
|
|
734
|
+
const cancelFixture = fixture({ port: cancelPort });
|
|
735
|
+
const running = await cancelFixture.coordinator.create(
|
|
736
|
+
owner,
|
|
737
|
+
createInput(),
|
|
738
|
+
);
|
|
739
|
+
let cancelOperationId = "";
|
|
740
|
+
try {
|
|
741
|
+
await cancelFixture.coordinator.cancel(owner, running.sideChatId, {
|
|
742
|
+
requestKey: "cancel-key-lost1",
|
|
743
|
+
});
|
|
744
|
+
} catch (error) {
|
|
745
|
+
expect(error).toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
|
|
746
|
+
cancelOperationId = (error as SideThreadError).operationId!;
|
|
747
|
+
}
|
|
748
|
+
await expect(
|
|
749
|
+
cancelFixture.coordinator.cancel(owner, running.sideChatId, {
|
|
750
|
+
requestKey: "cancel-key-lost1",
|
|
751
|
+
}),
|
|
752
|
+
).rejects.toMatchObject({
|
|
753
|
+
code: "SIDE_THREAD_AMBIGUOUS",
|
|
754
|
+
operationId: cancelOperationId,
|
|
755
|
+
});
|
|
756
|
+
expect(cancelPort.calls.cancel).toBe(2);
|
|
757
|
+
|
|
758
|
+
for (const action of ["archive", "purge"] as const) {
|
|
759
|
+
const port = new FakePort();
|
|
760
|
+
port[action] = async () => {
|
|
761
|
+
port.calls[action]++;
|
|
762
|
+
throw lose();
|
|
763
|
+
};
|
|
764
|
+
const current = fixture({ port });
|
|
765
|
+
const record = await current.coordinator.create(
|
|
766
|
+
owner,
|
|
767
|
+
createInput({ requestKey: `create-key-${action}` }),
|
|
768
|
+
);
|
|
769
|
+
const attempt = record.attempts[0];
|
|
770
|
+
port.emit({
|
|
771
|
+
sideChatId: record.sideChatId,
|
|
772
|
+
attemptId: attempt.attemptId,
|
|
773
|
+
threadId: record.threadId!,
|
|
774
|
+
turnId: attempt.turnId!,
|
|
775
|
+
status: "completed",
|
|
776
|
+
text: "done",
|
|
777
|
+
});
|
|
778
|
+
let operationId = "";
|
|
779
|
+
try {
|
|
780
|
+
await current.coordinator[action](owner, record.sideChatId, {
|
|
781
|
+
requestKey: `${action}-key-lost1`,
|
|
782
|
+
});
|
|
783
|
+
} catch (error) {
|
|
784
|
+
operationId = (error as SideThreadError).operationId!;
|
|
785
|
+
expect(error).toMatchObject({ code: "SIDE_THREAD_AMBIGUOUS" });
|
|
786
|
+
}
|
|
787
|
+
await expect(
|
|
788
|
+
current.coordinator[action](owner, record.sideChatId, {
|
|
789
|
+
requestKey: `${action}-key-lost1`,
|
|
790
|
+
}),
|
|
791
|
+
).rejects.toMatchObject({
|
|
792
|
+
code: "SIDE_THREAD_AMBIGUOUS",
|
|
793
|
+
operationId,
|
|
794
|
+
});
|
|
795
|
+
expect(port.calls[action]).toBe(2);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
describe("SideThread HTTP contract", () => {
|
|
801
|
+
test("authoritative vendorable golden fixtures cannot drift from HTTP projection", async () => {
|
|
802
|
+
expect(Object.keys(contractSchema.$defs).sort()).toEqual(
|
|
803
|
+
[
|
|
804
|
+
"actionRequest",
|
|
805
|
+
"attachment",
|
|
806
|
+
"attempt",
|
|
807
|
+
"boundary",
|
|
808
|
+
"bringBack",
|
|
809
|
+
"bringBackRequest",
|
|
810
|
+
"bringBackResponse",
|
|
811
|
+
"capabilityResponse",
|
|
812
|
+
"createRequest",
|
|
813
|
+
"error",
|
|
814
|
+
"operation",
|
|
815
|
+
"recordCapability",
|
|
816
|
+
"retryRequest",
|
|
817
|
+
"sideThread",
|
|
818
|
+
"sideThreadResponse",
|
|
819
|
+
"sideThreadsResponse",
|
|
820
|
+
"sseEvent",
|
|
821
|
+
].sort(),
|
|
822
|
+
);
|
|
823
|
+
const f = fixture();
|
|
824
|
+
const capabilityReq = new Request(
|
|
825
|
+
"http://hub/api/side-threads/capability?alias=node-a&networkId=net_a&sourceThreadId=source_thread&boundaryKind=through&boundaryTurnId=source_turn",
|
|
826
|
+
);
|
|
827
|
+
const capability = await handleSideThreadHttpRequest({
|
|
828
|
+
req: capabilityReq,
|
|
829
|
+
url: new URL(capabilityReq.url),
|
|
830
|
+
actor: owner,
|
|
831
|
+
coordinator: f.coordinator,
|
|
832
|
+
resolveCapabilityTarget: () => ({ nodeId: "node_a", networkId: "net_a" }),
|
|
833
|
+
});
|
|
834
|
+
expect(await capability!.json()).toEqual(contractGolden.capabilityResponse);
|
|
835
|
+
|
|
836
|
+
const request = new Request("http://hub/api/side-threads", {
|
|
837
|
+
method: "POST",
|
|
838
|
+
headers: { "Content-Type": "application/json" },
|
|
839
|
+
body: JSON.stringify(contractGolden.requests.create),
|
|
840
|
+
});
|
|
841
|
+
const created = await handleSideThreadHttpRequest({
|
|
842
|
+
req: request,
|
|
843
|
+
url: new URL(request.url),
|
|
844
|
+
actor: owner,
|
|
845
|
+
coordinator: f.coordinator,
|
|
846
|
+
});
|
|
847
|
+
expect(jsonShape(await created!.json())).toEqual(
|
|
848
|
+
jsonShape(contractGolden.sideThreadEnvelope),
|
|
849
|
+
);
|
|
850
|
+
const empty = fixture();
|
|
851
|
+
const listReq = new Request("http://hub/api/side-threads");
|
|
852
|
+
const listed = await handleSideThreadHttpRequest({
|
|
853
|
+
req: listReq,
|
|
854
|
+
url: new URL(listReq.url),
|
|
855
|
+
actor: owner,
|
|
856
|
+
coordinator: empty.coordinator,
|
|
857
|
+
});
|
|
858
|
+
expect(await listed!.json()).toEqual(contractGolden.listResponse);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("routes use the stable REST shape and return owner-readable question", async () => {
|
|
862
|
+
const f = fixture();
|
|
863
|
+
const req = new Request("http://hub/api/side-threads", {
|
|
864
|
+
method: "POST",
|
|
865
|
+
headers: { "Content-Type": "application/json" },
|
|
866
|
+
body: JSON.stringify({
|
|
867
|
+
requestKey: "create-key-http1",
|
|
868
|
+
networkId: "net_a",
|
|
869
|
+
nodeId: "node_a",
|
|
870
|
+
sourceThreadId: "source_thread",
|
|
871
|
+
boundary: { kind: "through", turnId: "source_turn" },
|
|
872
|
+
prompt: "full question",
|
|
873
|
+
attachments: [{ fileId: "file_ref_0001" }],
|
|
874
|
+
}),
|
|
875
|
+
});
|
|
876
|
+
const created = await handleSideThreadHttpRequest({
|
|
877
|
+
req,
|
|
878
|
+
url: new URL(req.url),
|
|
879
|
+
actor: owner,
|
|
880
|
+
coordinator: f.coordinator,
|
|
881
|
+
});
|
|
882
|
+
expect(created?.status).toBe(201);
|
|
883
|
+
const body = (await created!.json()) as any;
|
|
884
|
+
expect(body.sideThread).toMatchObject({
|
|
885
|
+
requestKey: "create-key-http1",
|
|
886
|
+
question: "full question",
|
|
887
|
+
title: "full question",
|
|
888
|
+
state: "running",
|
|
889
|
+
});
|
|
890
|
+
const getReq = new Request(
|
|
891
|
+
`http://hub/api/side-threads/${body.sideThread.sideThreadId}`,
|
|
892
|
+
);
|
|
893
|
+
const got = await handleSideThreadHttpRequest({
|
|
894
|
+
req: getReq,
|
|
895
|
+
url: new URL(getReq.url),
|
|
896
|
+
actor: owner,
|
|
897
|
+
coordinator: f.coordinator,
|
|
898
|
+
});
|
|
899
|
+
expect(((await got!.json()) as any).sideThread.question).toBe(
|
|
900
|
+
"full question",
|
|
901
|
+
);
|
|
902
|
+
const listReq = new Request("http://hub/api/side-threads?network_id=net_a");
|
|
903
|
+
const listed = await handleSideThreadHttpRequest({
|
|
904
|
+
req: listReq,
|
|
905
|
+
url: new URL(listReq.url),
|
|
906
|
+
actor: owner,
|
|
907
|
+
coordinator: f.coordinator,
|
|
908
|
+
});
|
|
909
|
+
expect((await listed!.json()) as any).toMatchObject({
|
|
910
|
+
count: 1,
|
|
911
|
+
sideThreads: [
|
|
912
|
+
{
|
|
913
|
+
question: "full question",
|
|
914
|
+
bringBacks: [],
|
|
915
|
+
operations: expect.any(Array),
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
});
|
|
919
|
+
const denied = await handleSideThreadHttpRequest({
|
|
920
|
+
req: getReq,
|
|
921
|
+
url: new URL(getReq.url),
|
|
922
|
+
actor: stranger,
|
|
923
|
+
coordinator: f.coordinator,
|
|
924
|
+
});
|
|
925
|
+
expect(denied?.status).toBe(404);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("every mutating action binds the caller requestKey into its durable operation", async () => {
|
|
929
|
+
const f = fixture();
|
|
930
|
+
const record = await f.coordinator.create(owner, createInput());
|
|
931
|
+
for (const action of ["cancel", "archive", "purge"] as const) {
|
|
932
|
+
const request = new Request(
|
|
933
|
+
`http://hub/api/side-threads/${record.sideChatId}/${action}`,
|
|
934
|
+
{
|
|
935
|
+
method: "POST",
|
|
936
|
+
headers: { "Content-Type": "application/json" },
|
|
937
|
+
body: JSON.stringify(contractGolden.requests[action]),
|
|
938
|
+
},
|
|
939
|
+
);
|
|
940
|
+
const response = await handleSideThreadHttpRequest({
|
|
941
|
+
req: request,
|
|
942
|
+
url: new URL(request.url),
|
|
943
|
+
actor: owner,
|
|
944
|
+
coordinator: f.coordinator,
|
|
945
|
+
});
|
|
946
|
+
expect(response?.status).toBe(200);
|
|
947
|
+
if (action === "cancel") {
|
|
948
|
+
const attempt = record.attempts[0];
|
|
949
|
+
f.port.emit({ sideChatId: record.sideChatId, attemptId: attempt.attemptId, threadId: record.threadId!, turnId: attempt.turnId!, status: "interrupted" });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const hydrated = f.coordinator.get(owner, record.sideChatId);
|
|
953
|
+
expect(
|
|
954
|
+
hydrated.operations
|
|
955
|
+
.filter((operation) =>
|
|
956
|
+
["cancel", "archive", "purge"].includes(operation.kind),
|
|
957
|
+
)
|
|
958
|
+
.map((operation) => [operation.kind, operation.requestKey]),
|
|
959
|
+
).toEqual([
|
|
960
|
+
["cancel", contractGolden.requests.cancel.requestKey],
|
|
961
|
+
["archive", contractGolden.requests.archive.requestKey],
|
|
962
|
+
["purge", contractGolden.requests.purge.requestKey],
|
|
963
|
+
]);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("disabled surface is 404 before auth and unsupported adapter is typed 501", async () => {
|
|
967
|
+
const off = fixture({ enabled: false });
|
|
968
|
+
const getReq = new Request("http://hub/api/side-threads/anything");
|
|
969
|
+
expect(
|
|
970
|
+
(
|
|
971
|
+
await handleSideThreadHttpRequest({
|
|
972
|
+
req: getReq,
|
|
973
|
+
url: new URL(getReq.url),
|
|
974
|
+
actor: null,
|
|
975
|
+
coordinator: off.coordinator,
|
|
976
|
+
})
|
|
977
|
+
)?.status,
|
|
978
|
+
).toBe(404);
|
|
979
|
+
const unsupported = fixture({ port: new UnsupportedSideThreadPort() });
|
|
980
|
+
const req = new Request("http://hub/api/side-threads", {
|
|
981
|
+
method: "POST",
|
|
982
|
+
headers: { "Content-Type": "application/json" },
|
|
983
|
+
body: JSON.stringify({
|
|
984
|
+
request_key: "create-key-http2",
|
|
985
|
+
network_id: "net_a",
|
|
986
|
+
node_id: "node_a",
|
|
987
|
+
source_thread_id: "source_thread",
|
|
988
|
+
boundary: { kind: "through", turn_id: "source_turn" },
|
|
989
|
+
prompt: "question",
|
|
990
|
+
}),
|
|
991
|
+
});
|
|
992
|
+
const response = await handleSideThreadHttpRequest({
|
|
993
|
+
req,
|
|
994
|
+
url: new URL(req.url),
|
|
995
|
+
actor: owner,
|
|
996
|
+
coordinator: unsupported.coordinator,
|
|
997
|
+
});
|
|
998
|
+
expect(response?.status).toBe(501);
|
|
999
|
+
expect(await response!.json()).toMatchObject({
|
|
1000
|
+
error: "SIDE_THREAD_UNSUPPORTED",
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("capability flips and ambiguous runtime identities are typed failures", async () => {
|
|
1005
|
+
const flippedPort = new FakePort();
|
|
1006
|
+
flippedPort.fork = async () => {
|
|
1007
|
+
const error = new Error("capability disappeared");
|
|
1008
|
+
(error as any).code = "SIDE_THREAD_UNSUPPORTED";
|
|
1009
|
+
throw error;
|
|
1010
|
+
};
|
|
1011
|
+
const flipped = fixture({ port: flippedPort });
|
|
1012
|
+
const flipReq = new Request("http://hub/api/side-threads", {
|
|
1013
|
+
method: "POST",
|
|
1014
|
+
headers: { "Content-Type": "application/json" },
|
|
1015
|
+
body: JSON.stringify({
|
|
1016
|
+
request_key: "create-key-flip1",
|
|
1017
|
+
network_id: "net_a",
|
|
1018
|
+
node_id: "node_a",
|
|
1019
|
+
source_thread_id: "source_thread",
|
|
1020
|
+
boundary: { kind: "through", turn_id: "source_turn" },
|
|
1021
|
+
prompt: "flip",
|
|
1022
|
+
}),
|
|
1023
|
+
});
|
|
1024
|
+
const flipResponse = await handleSideThreadHttpRequest({
|
|
1025
|
+
req: flipReq,
|
|
1026
|
+
url: new URL(flipReq.url),
|
|
1027
|
+
actor: owner,
|
|
1028
|
+
coordinator: flipped.coordinator,
|
|
1029
|
+
});
|
|
1030
|
+
expect(flipResponse?.status).toBe(501);
|
|
1031
|
+
expect(await flipResponse!.json()).toMatchObject({
|
|
1032
|
+
error: "SIDE_THREAD_UNSUPPORTED",
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
const ambiguousPort = new FakePort();
|
|
1036
|
+
ambiguousPort.fork = async () => ({ threadId: undefined as any });
|
|
1037
|
+
const ambiguous = fixture({ port: ambiguousPort });
|
|
1038
|
+
const ambiguousReq = new Request("http://hub/api/side-threads", {
|
|
1039
|
+
method: "POST",
|
|
1040
|
+
headers: { "Content-Type": "application/json" },
|
|
1041
|
+
body: JSON.stringify({
|
|
1042
|
+
request_key: "create-key-ambig1",
|
|
1043
|
+
network_id: "net_a",
|
|
1044
|
+
node_id: "node_a",
|
|
1045
|
+
source_thread_id: "source_thread",
|
|
1046
|
+
boundary: { kind: "through", turn_id: "source_turn" },
|
|
1047
|
+
prompt: "ambiguous",
|
|
1048
|
+
}),
|
|
1049
|
+
});
|
|
1050
|
+
const ambiguousResponse = await handleSideThreadHttpRequest({
|
|
1051
|
+
req: ambiguousReq,
|
|
1052
|
+
url: new URL(ambiguousReq.url),
|
|
1053
|
+
actor: owner,
|
|
1054
|
+
coordinator: ambiguous.coordinator,
|
|
1055
|
+
});
|
|
1056
|
+
expect(ambiguousResponse?.status).toBe(202);
|
|
1057
|
+
expect(await ambiguousResponse!.json()).toMatchObject({
|
|
1058
|
+
error: "SIDE_THREAD_AMBIGUOUS",
|
|
1059
|
+
operationId: expect.stringMatching(/^sop_/),
|
|
1060
|
+
sideThreadId: expect.stringMatching(/^sch_/),
|
|
1061
|
+
attemptId: expect.stringMatching(/^sat_/),
|
|
1062
|
+
});
|
|
1063
|
+
expect(
|
|
1064
|
+
ambiguous.store.getByCreateKey(owner, "create-key-ambig1")?.record.state,
|
|
1065
|
+
).toBe("ambiguous");
|
|
1066
|
+
});
|
|
1067
|
+
});
|