@tea-agent/loop-agent 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/README.md +1 -1
- package/bin/agent-worker.js +0 -0
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +475 -2
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/artifact-card.js +23 -0
- package/dist/worker/console/chat/chat-event-store.js +495 -0
- package/dist/worker/console/chat/chat-ui-policy.js +25 -0
- package/dist/worker/console/chat/composer-draft-store.js +45 -0
- package/dist/worker/console/chat/context-panel.js +54 -0
- package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
- package/dist/worker/console/chat/explore-tools.js +299 -0
- package/dist/worker/console/chat/human-gate-card.js +37 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/operation-card.js +23 -0
- package/dist/worker/console/chat/pi-console-config.js +158 -0
- package/dist/worker/console/chat/pi-runtime.js +581 -43
- package/dist/worker/console/chat/repo-browser.js +140 -0
- package/dist/worker/console/chat/repo-walk.js +116 -0
- package/dist/worker/console/chat/resource-loader.js +18 -17
- package/dist/worker/console/chat/routes.js +1354 -65
- package/dist/worker/console/chat/runtime-context.js +24 -0
- package/dist/worker/console/chat/runtime-selection.js +37 -0
- package/dist/worker/console/chat/session-store.js +210 -11
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +81 -194
- package/dist/worker/console/chat/tools.js +72 -48
- package/dist/worker/console/chat/usage.js +37 -0
- package/dist/worker/console/chat/workspace-landing.js +56 -0
- package/dist/worker/console/dag-confirmation.js +42 -8
- package/dist/worker/console/human-gate-token.js +130 -0
- package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
- package/dist/worker/console/operation-runner.js +6 -2
- package/dist/worker/console/operation-sse.js +26 -0
- package/dist/worker/console/operator-actions.js +420 -7
- package/dist/worker/console/server.js +14 -2
- package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
- package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
- package/dist/workflows/dag/backend-test-result-contract.js +229 -0
- package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
- package/dist/workflows/dag/init-hybrid.js +2 -1
- package/docs/README.md +1 -1
- package/docs/architecture/README.md +5 -5
- package/docs/architecture/evolution.md +4 -4
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/templates/backend-test-dag.json +2 -2
- package/harness.json +1 -1
- package/package.json +1 -1
- package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
- package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readJsonIfExists, sha256Utf8, writeSecureJson, } from "./app-data.js";
|
|
4
|
+
import { confirmationPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "./human-gate-token.js";
|
|
4
5
|
export const ALL_CONFIRMATION_CHALLENGES = [
|
|
5
6
|
"source",
|
|
6
7
|
"writer-scope",
|
|
@@ -88,8 +89,15 @@ function detectBindingDrift(rec, current) {
|
|
|
88
89
|
}
|
|
89
90
|
export class DagConfirmationStore {
|
|
90
91
|
appData;
|
|
91
|
-
|
|
92
|
+
humanGateSecret;
|
|
93
|
+
constructor(appData,
|
|
94
|
+
/**
|
|
95
|
+
* M0-B: per-process HMAC secret for human-gate tokens
|
|
96
|
+
* (BootCapabilityToken.confirmationToken). Required for prepare/confirm.
|
|
97
|
+
*/
|
|
98
|
+
humanGateSecret) {
|
|
92
99
|
this.appData = appData;
|
|
100
|
+
this.humanGateSecret = humanGateSecret;
|
|
93
101
|
}
|
|
94
102
|
async get(confirmationId) {
|
|
95
103
|
const rec = await readJsonIfExists(confirmationPath(this.appData, confirmationId));
|
|
@@ -114,15 +122,28 @@ export class DagConfirmationStore {
|
|
|
114
122
|
const now = input.now ?? new Date();
|
|
115
123
|
const ttl = input.ttlMs ?? DEFAULT_CONFIRMATION_TTL_MS;
|
|
116
124
|
const confirmationId = newConfirmationId();
|
|
125
|
+
const dagSha256 = hashDagBytes(input.dagBytes);
|
|
126
|
+
const expiresAt = new Date(now.getTime() + ttl).toISOString();
|
|
127
|
+
// M0-B: issue a server-signed human-gate token bound to the confirmation's
|
|
128
|
+
// payloadHash. The browser must present it at confirm; dispatch verifies
|
|
129
|
+
// the signature so a model cannot self-confirm.
|
|
130
|
+
const payloadHash = confirmationPayloadHash({
|
|
131
|
+
dagSha256,
|
|
132
|
+
sourceBindingSha256: input.sourceBindingSha256,
|
|
133
|
+
taskContractBindingCanonical: JSON.stringify(input.taskContractBinding),
|
|
134
|
+
controllerFingerprint: input.controllerFingerprint,
|
|
135
|
+
validationResultSha256: input.validationResultSha256,
|
|
136
|
+
});
|
|
137
|
+
const humanGateToken = issueHumanGateToken({ confirmationId, payloadHash, expiresAt }, this.humanGateSecret, now);
|
|
117
138
|
const rec = {
|
|
118
139
|
schemaVersion: 1,
|
|
119
140
|
confirmationId,
|
|
120
141
|
state: "prepared",
|
|
121
142
|
preparedAt: now.toISOString(),
|
|
122
|
-
expiresAt
|
|
143
|
+
expiresAt,
|
|
123
144
|
operatorSessionId: input.operatorSessionId,
|
|
124
145
|
dagHandle: input.dagHandle,
|
|
125
|
-
dagSha256
|
|
146
|
+
dagSha256,
|
|
126
147
|
dagBytesPath: input.dagBytesPath,
|
|
127
148
|
sourceBindingSha256: input.sourceBindingSha256,
|
|
128
149
|
taskContractBinding: input.taskContractBinding,
|
|
@@ -130,6 +151,7 @@ export class DagConfirmationStore {
|
|
|
130
151
|
validationResultSha256: input.validationResultSha256,
|
|
131
152
|
confirmedWriterScopes: input.confirmedWriterScopes ?? [],
|
|
132
153
|
confirmedChallenges: [],
|
|
154
|
+
humanGateToken,
|
|
133
155
|
reviewPacket: input.reviewPacket,
|
|
134
156
|
};
|
|
135
157
|
await writeSecureJson(confirmationPath(this.appData, confirmationId), rec);
|
|
@@ -195,14 +217,25 @@ export class DagConfirmationStore {
|
|
|
195
217
|
confirmation: stale,
|
|
196
218
|
};
|
|
197
219
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
// M0-B / roadmap G03: verify the human-gate token's HMAC signature. The
|
|
221
|
+
// old fixed-string challenge set (`source`/`writer-scope`/...) was a fake
|
|
222
|
+
// gate — a model could fill those strings directly. Now the browser must
|
|
223
|
+
// present the server-signed token issued at prepare; the signature binds
|
|
224
|
+
// (confirmationId + payloadHash + expiresAt) and is verified with the
|
|
225
|
+
// per-process boot secret. A model-side call has no way to forge it.
|
|
226
|
+
const payloadHash = confirmationPayloadHash({
|
|
227
|
+
dagSha256: current.dagSha256,
|
|
228
|
+
sourceBindingSha256: current.sourceBindingSha256,
|
|
229
|
+
taskContractBindingCanonical: JSON.stringify(current.taskContractBinding),
|
|
230
|
+
controllerFingerprint: current.controllerFingerprint,
|
|
231
|
+
validationResultSha256: current.validationResultSha256,
|
|
232
|
+
});
|
|
233
|
+
const tokenCheck = verifyHumanGateToken(input.humanGateToken, { confirmationId: input.confirmationId, payloadHash }, this.humanGateSecret, input.now);
|
|
234
|
+
if (!tokenCheck.ok) {
|
|
202
235
|
return {
|
|
203
236
|
ok: false,
|
|
204
237
|
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
205
|
-
message: `
|
|
238
|
+
message: `human-gate token verification failed: ${tokenCheck.code} — ${tokenCheck.message}`,
|
|
206
239
|
confirmation: rec,
|
|
207
240
|
};
|
|
208
241
|
}
|
|
@@ -212,6 +245,7 @@ export class DagConfirmationStore {
|
|
|
212
245
|
state: "confirmed",
|
|
213
246
|
confirmedAt: now.toISOString(),
|
|
214
247
|
confirmedChallenges: [...ALL_CONFIRMATION_CHALLENGES],
|
|
248
|
+
humanGateToken: input.humanGateToken ?? rec.humanGateToken,
|
|
215
249
|
};
|
|
216
250
|
await writeSecureJson(confirmationPath(this.appData, rec.confirmationId), confirmed);
|
|
217
251
|
return { ok: true, confirmation: confirmed };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — Human Gate confirmation token (roadmap M0-B / G03).
|
|
3
|
+
*
|
|
4
|
+
* Closes the "fake human gate" gap (roadmap §3.2 risk 1): the old
|
|
5
|
+
* confirmDagConfirmation only checked that a FIXED set of challenge strings
|
|
6
|
+
* (`source`/`writer-scope`/`verification`/`human-gates`) was present — a model
|
|
7
|
+
* could fill those in directly and self-confirm. This module issues a
|
|
8
|
+
* server-signed token bound to (confirmationId, payloadHash, expiresAt) and
|
|
9
|
+
* verifies its HMAC signature at dispatch time, so confirmation can only
|
|
10
|
+
* originate from the browser mutation gate that received the signed token.
|
|
11
|
+
*
|
|
12
|
+
* The HMAC key is the per-process boot confirmation secret
|
|
13
|
+
* (BootCapabilityToken.confirmationToken). This binds the human-gate token to
|
|
14
|
+
* the same Console boot session that the browser established via the HttpOnly
|
|
15
|
+
* cookie — a model-side tool call cannot fabricate it.
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
18
|
+
/** Canonical message string covered by the signature. */
|
|
19
|
+
export function canonicalTokenMessage(p) {
|
|
20
|
+
return [
|
|
21
|
+
`v1`,
|
|
22
|
+
`confirmationId=${p.confirmationId}`,
|
|
23
|
+
`payloadHash=${p.payloadHash}`,
|
|
24
|
+
`expiresAt=${p.expiresAt}`,
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
|
27
|
+
function b64url(buf) {
|
|
28
|
+
return buf.toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
/** Issue a signed token. `secret` = BootCapabilityToken.confirmationToken. */
|
|
31
|
+
export function issueHumanGateToken(payload, secret, now = new Date()) {
|
|
32
|
+
const sig = createHmac("sha256", secret).update(canonicalTokenMessage(payload)).digest();
|
|
33
|
+
return {
|
|
34
|
+
...payload,
|
|
35
|
+
signature: b64url(sig),
|
|
36
|
+
issuedAt: now.toISOString(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify a presented token. Checks signature, expiry, and that the token is
|
|
41
|
+
* bound to the given confirmationId + current payloadHash. Constant-time
|
|
42
|
+
* signature compare. Returns a typed failure (no thrown secrets).
|
|
43
|
+
*/
|
|
44
|
+
export function verifyHumanGateToken(presented, expected, secret, now = new Date()) {
|
|
45
|
+
if (!presented || typeof presented !== "object") {
|
|
46
|
+
return { ok: false, code: "MISSING_TOKEN", message: "no human-gate token presented" };
|
|
47
|
+
}
|
|
48
|
+
if (presented.confirmationId !== expected.confirmationId) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
code: "CONFIRMATION_MISMATCH",
|
|
52
|
+
message: "token is bound to a different confirmationId",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (presented.payloadHash !== expected.payloadHash) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: "PAYLOAD_MISMATCH",
|
|
59
|
+
message: "token payloadHash does not match the current confirmation bindings",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const expectedSig = createHmac("sha256", secret)
|
|
63
|
+
.update(canonicalTokenMessage(presented))
|
|
64
|
+
.digest();
|
|
65
|
+
let presentedSig;
|
|
66
|
+
try {
|
|
67
|
+
presentedSig = Buffer.from(presented.signature, "base64url");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { ok: false, code: "INVALID_SIGNATURE", message: "signature is not valid base64url" };
|
|
71
|
+
}
|
|
72
|
+
if (presentedSig.length !== expectedSig.length ||
|
|
73
|
+
!timingSafeEqual(presentedSig, expectedSig)) {
|
|
74
|
+
return { ok: false, code: "INVALID_SIGNATURE", message: "signature verification failed" };
|
|
75
|
+
}
|
|
76
|
+
if (Date.parse(presented.expiresAt) <= now.getTime()) {
|
|
77
|
+
return { ok: false, code: "EXPIRED", message: "token has expired" };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, token: presented };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Compute the payloadHash for a confirmation receipt: SHA-256 over the binding
|
|
83
|
+
* snapshot (dagSha256 + sourceBindingSha256 + taskContractBinding canonical +
|
|
84
|
+
* controllerFingerprint + validationResultSha256). Changing any binding
|
|
85
|
+
* invalidates the token (defense against drift-then-confirm).
|
|
86
|
+
*/
|
|
87
|
+
import { createHash } from "node:crypto";
|
|
88
|
+
import { sha256Utf8 } from "./app-data.js";
|
|
89
|
+
export function confirmationPayloadHash(b) {
|
|
90
|
+
const canonical = [
|
|
91
|
+
`dag=${b.dagSha256}`,
|
|
92
|
+
`source=${b.sourceBindingSha256}`,
|
|
93
|
+
`contract=${sha256Utf8(b.taskContractBindingCanonical)}`,
|
|
94
|
+
`controller=${b.controllerFingerprint}`,
|
|
95
|
+
`validation=${b.validationResultSha256}`,
|
|
96
|
+
].join("\n");
|
|
97
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
98
|
+
}
|
|
99
|
+
/** Stable contract-apply binding; revision and observed hash prevent drift-then-confirm. */
|
|
100
|
+
export function contractApplyPayloadHash(b) {
|
|
101
|
+
const canonical = [
|
|
102
|
+
"v1-contract-apply",
|
|
103
|
+
`taskId=${b.taskId}`,
|
|
104
|
+
`revision=${b.revision}`,
|
|
105
|
+
`canonicalHash=${b.canonicalHash}`,
|
|
106
|
+
`draftSha256=${b.draftSha256}`,
|
|
107
|
+
`expectedObservedHash=${b.expectedObservedHash}`,
|
|
108
|
+
].join("\n");
|
|
109
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
110
|
+
}
|
|
111
|
+
/** Stable mutation-gate binding; params are canonical-JSON with sorted keys. */
|
|
112
|
+
export function mutationGatePayloadHash(b) {
|
|
113
|
+
const paramsCanonical = stableJson(b.actionParams);
|
|
114
|
+
const canonical = [
|
|
115
|
+
"v1-mutation-gate",
|
|
116
|
+
`action=${b.action}`,
|
|
117
|
+
`operatorSessionId=${b.operatorSessionId}`,
|
|
118
|
+
`actionParams=${paramsCanonical}`,
|
|
119
|
+
].join("\n");
|
|
120
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
121
|
+
}
|
|
122
|
+
function stableJson(value) {
|
|
123
|
+
if (value === null || typeof value !== "object")
|
|
124
|
+
return JSON.stringify(value);
|
|
125
|
+
if (Array.isArray(value))
|
|
126
|
+
return `[${value.map((entry) => stableJson(entry)).join(",")}]`;
|
|
127
|
+
const obj = value;
|
|
128
|
+
const keys = Object.keys(obj).sort();
|
|
129
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`).join(",")}}`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Human Gate receipts for mutation actions that are not DAG-bound
|
|
3
|
+
* (standaloneTaskRerun / workerTaskRetry). Mirrors contract-apply receipt
|
|
4
|
+
* semantics with an atomic prepared → dispatching → dispatched state machine
|
|
5
|
+
* so concurrent confirm POSTs cannot create duplicate operations.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
export class MutationGateReceiptStore {
|
|
10
|
+
dir;
|
|
11
|
+
constructor(appData) {
|
|
12
|
+
this.dir = path.join(appData.root, "mutation-gate-receipts");
|
|
13
|
+
}
|
|
14
|
+
file(id) {
|
|
15
|
+
return path.join(this.dir, `${id.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
|
|
16
|
+
}
|
|
17
|
+
async prepare(input) {
|
|
18
|
+
const receipt = {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
state: "prepared",
|
|
21
|
+
...input,
|
|
22
|
+
};
|
|
23
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
await writeFile(this.file(receipt.receiptId), `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
25
|
+
return receipt;
|
|
26
|
+
}
|
|
27
|
+
async get(id) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(this.file(id), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === "ENOENT")
|
|
33
|
+
return undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Atomically claim a prepared receipt for dispatch.
|
|
39
|
+
* Concurrent callers: only one gets reserved=true; others see
|
|
40
|
+
* already-dispatching / already-dispatched with the same receipt.
|
|
41
|
+
*/
|
|
42
|
+
async tryReserve(id) {
|
|
43
|
+
const receipt = await this.get(id);
|
|
44
|
+
if (!receipt) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
code: "NOT_FOUND",
|
|
48
|
+
message: `mutation gate receipt not found: ${id}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (Date.parse(receipt.expiresAt) <= Date.now()) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
code: "EXPIRED",
|
|
55
|
+
message: "mutation gate receipt expired",
|
|
56
|
+
receipt,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (receipt.state === "dispatching") {
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
receipt,
|
|
63
|
+
reserved: false,
|
|
64
|
+
reason: "already-dispatching",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (receipt.state === "dispatched") {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
receipt,
|
|
71
|
+
reserved: false,
|
|
72
|
+
reason: "already-dispatched",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (receipt.state !== "prepared") {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
code: "INVALID_STATE",
|
|
79
|
+
message: `mutation gate receipt is ${receipt.state}`,
|
|
80
|
+
receipt,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const next = {
|
|
84
|
+
...receipt,
|
|
85
|
+
state: "dispatching",
|
|
86
|
+
reservedAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
const claimed = await this.compareAndSwap(id, "prepared", next);
|
|
89
|
+
if (!claimed.ok) {
|
|
90
|
+
const current = claimed.current;
|
|
91
|
+
if (current?.state === "dispatching") {
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
receipt: current,
|
|
95
|
+
reserved: false,
|
|
96
|
+
reason: "already-dispatching",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (current?.state === "dispatched") {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
receipt: current,
|
|
103
|
+
reserved: false,
|
|
104
|
+
reason: "already-dispatched",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
code: "INVALID_STATE",
|
|
110
|
+
message: `mutation gate receipt raced into ${current?.state ?? "missing"}`,
|
|
111
|
+
receipt: current,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, receipt: next, reserved: true };
|
|
115
|
+
}
|
|
116
|
+
async markDispatched(id, operationId) {
|
|
117
|
+
const receipt = await this.get(id);
|
|
118
|
+
if (!receipt)
|
|
119
|
+
throw new Error(`receipt not found: ${id}`);
|
|
120
|
+
const next = {
|
|
121
|
+
...receipt,
|
|
122
|
+
state: "dispatched",
|
|
123
|
+
operationId,
|
|
124
|
+
dispatchedAt: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
await writeFile(this.file(id), `${JSON.stringify(next, null, 2)}\n`, {
|
|
127
|
+
mode: 0o600,
|
|
128
|
+
});
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
/** Best-effort unlock if dispatch failed after reserve. */
|
|
132
|
+
async releaseToPrepared(id) {
|
|
133
|
+
const receipt = await this.get(id);
|
|
134
|
+
if (!receipt || receipt.state !== "dispatching")
|
|
135
|
+
return receipt;
|
|
136
|
+
const next = {
|
|
137
|
+
...receipt,
|
|
138
|
+
state: "prepared",
|
|
139
|
+
reservedAt: undefined,
|
|
140
|
+
};
|
|
141
|
+
await writeFile(this.file(id), `${JSON.stringify(next, null, 2)}\n`, {
|
|
142
|
+
mode: 0o600,
|
|
143
|
+
});
|
|
144
|
+
return next;
|
|
145
|
+
}
|
|
146
|
+
async compareAndSwap(id, expectedState, next) {
|
|
147
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
148
|
+
const target = this.file(id);
|
|
149
|
+
const lockPath = `${target}.lock`;
|
|
150
|
+
try {
|
|
151
|
+
await writeFile(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error.code === "EEXIST") {
|
|
155
|
+
// Brief spin for the other writer; then re-read.
|
|
156
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
157
|
+
return { ok: false, current: await this.get(id) };
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const current = await this.get(id);
|
|
163
|
+
if (!current || current.state !== expectedState) {
|
|
164
|
+
return { ok: false, current };
|
|
165
|
+
}
|
|
166
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
167
|
+
await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, {
|
|
168
|
+
mode: 0o600,
|
|
169
|
+
});
|
|
170
|
+
await rename(tmp, target);
|
|
171
|
+
return { ok: true };
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
try {
|
|
175
|
+
await writeFile(lockPath, "", { flag: "w" });
|
|
176
|
+
const { unlink } = await import("node:fs/promises");
|
|
177
|
+
await unlink(lockPath);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// lock cleanup is best-effort
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -155,15 +155,19 @@ function coerceEnvelope(result) {
|
|
|
155
155
|
/** Fire-and-forget scheduler for accepted operations. */
|
|
156
156
|
export function scheduleOperation(operationId, deps) {
|
|
157
157
|
void runOperation(operationId, deps).catch((error) => {
|
|
158
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
159
|
void deps.store
|
|
159
160
|
.update(operationId, {
|
|
160
161
|
state: "needs-reconcile",
|
|
161
162
|
finishedAt: new Date().toISOString(),
|
|
162
163
|
errorCode: "OPERATION_NEEDS_RECONCILE",
|
|
163
|
-
errorMessage:
|
|
164
|
+
errorMessage: message,
|
|
165
|
+
})
|
|
166
|
+
.then((updated) => {
|
|
167
|
+
stateEvent(deps.events, updated, "needs-reconcile", message);
|
|
164
168
|
})
|
|
165
169
|
.catch(() => {
|
|
166
|
-
//
|
|
170
|
+
// A failed canonical store update cannot be represented safely.
|
|
167
171
|
});
|
|
168
172
|
});
|
|
169
173
|
}
|
|
@@ -13,6 +13,7 @@ export function createOperationEventStore(options) {
|
|
|
13
13
|
if (persistenceDir)
|
|
14
14
|
ensureSecureDir(persistenceDir);
|
|
15
15
|
const rings = new Map();
|
|
16
|
+
const subscribers = new Map();
|
|
16
17
|
function persistedPath(operationId) {
|
|
17
18
|
return persistenceDir
|
|
18
19
|
? path.join(persistenceDir, `${safeOperationId(operationId)}.json`)
|
|
@@ -91,6 +92,17 @@ export function createOperationEventStore(options) {
|
|
|
91
92
|
ring.minSeq = ring.events[0].seq;
|
|
92
93
|
}
|
|
93
94
|
persist(operationId, ring);
|
|
95
|
+
const listeners = subscribers.get(operationId);
|
|
96
|
+
if (listeners) {
|
|
97
|
+
for (const listener of listeners) {
|
|
98
|
+
try {
|
|
99
|
+
listener(event);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Subscriber failures must never break the canonical append path.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
94
106
|
return event;
|
|
95
107
|
},
|
|
96
108
|
listFrom(operationId, afterSeq) {
|
|
@@ -112,8 +124,22 @@ export function createOperationEventStore(options) {
|
|
|
112
124
|
rings.set(operationId, ring);
|
|
113
125
|
return ring?.events.slice() ?? [];
|
|
114
126
|
},
|
|
127
|
+
subscribe(operationId, listener) {
|
|
128
|
+
let listeners = subscribers.get(operationId);
|
|
129
|
+
if (!listeners) {
|
|
130
|
+
listeners = new Set();
|
|
131
|
+
subscribers.set(operationId, listeners);
|
|
132
|
+
}
|
|
133
|
+
listeners.add(listener);
|
|
134
|
+
return () => {
|
|
135
|
+
listeners?.delete(listener);
|
|
136
|
+
if (listeners?.size === 0)
|
|
137
|
+
subscribers.delete(operationId);
|
|
138
|
+
};
|
|
139
|
+
},
|
|
115
140
|
clear(operationId) {
|
|
116
141
|
rings.delete(operationId);
|
|
142
|
+
subscribers.delete(operationId);
|
|
117
143
|
const file = persistedPath(operationId);
|
|
118
144
|
if (file)
|
|
119
145
|
rmSync(file, { force: true });
|