@awak-app/simy-cli 0.5.33 → 0.5.35
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 +41 -0
- package/package.json +4 -3
- package/src/agent.js +144 -2
- package/src/backend-executable.js +2 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +4 -1
- package/src/codex-plugins/simy/assets/logo.png +0 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +654 -35
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +20 -2
- package/src/codex-work-pattern-sync.js +940 -0
- package/src/delivery-hooks/command.js +28 -7
- package/src/delivery-hooks/desktop.js +38 -4
- package/src/delivery-hooks/supervisor.js +281 -0
- package/src/desktop-host.js +62 -7
- package/src/index.js +7 -0
- package/src/local-task.js +96 -3
- package/src/orchestrator/budget.js +26 -2
- package/src/runner.js +43 -8
- package/src/session-supervision/claude-sessions.js +131 -0
- package/src/session-supervision/codex-app-server.js +290 -0
- package/src/session-supervision/index.js +140 -0
- package/src/session-supervision/original-session-target.js +20 -0
- package/src/windows-update.js +28 -2
- package/src/workflow-deliverable-upload.js +454 -0
- package/src/workflow-observation-sender.js +348 -86
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
2
4
|
|
|
3
5
|
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
@@ -5,16 +7,56 @@ const SOURCE_REF = /^(codex|claude):[0-9a-f]{64}$/;
|
|
|
5
7
|
const WORKFLOW_UUID =
|
|
6
8
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
9
|
|
|
10
|
+
export const WORKFLOW_START_PATH = "workflow-dev-work-events/start";
|
|
11
|
+
export const WORKFLOW_TERMINAL_PATH = "workflow-dev-work-events/terminal";
|
|
12
|
+
export const TERMINAL_CONTRACT_VERSION = "independent_workflow_terminal_observation.v1";
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
14
|
+
const START_ATTEMPTS = 3;
|
|
15
|
+
const TERMINAL_ATTEMPTS = 2;
|
|
16
|
+
|
|
17
|
+
export function normalizeWorkflowStartIntent(value, { provider = null } = {}) {
|
|
18
|
+
if (value === undefined || value === null) return null;
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw bindingError();
|
|
20
|
+
const keys = Object.keys(value).sort();
|
|
21
|
+
const expected = ["idempotency_key", "source_ref", "workflow_id"];
|
|
22
|
+
if (
|
|
23
|
+
keys.length !== expected.length ||
|
|
24
|
+
keys.some((key, index) => key !== expected[index]) ||
|
|
25
|
+
typeof value.workflow_id !== "string" ||
|
|
26
|
+
!WORKFLOW_UUID.test(value.workflow_id) ||
|
|
27
|
+
typeof value.source_ref !== "string" ||
|
|
28
|
+
!SOURCE_REF.test(value.source_ref) ||
|
|
29
|
+
typeof value.idempotency_key !== "string" ||
|
|
30
|
+
value.idempotency_key.length < 8 ||
|
|
31
|
+
value.idempotency_key.length > 200 ||
|
|
32
|
+
(provider !== null && !value.source_ref.startsWith(`${provider}:`))
|
|
33
|
+
) throw bindingError();
|
|
34
|
+
// UUIDs are validated case-insensitively, so they are compared only in the
|
|
35
|
+
// canonical lowercase form that crew/Web return (Python str(UUID)).
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
workflow_id: value.workflow_id.toLowerCase(),
|
|
38
|
+
source_ref: String(value.source_ref),
|
|
39
|
+
idempotency_key: String(value.idempotency_key),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
8
43
|
export function normalizeWorkflowObservationBinding(value, { provider = null } = {}) {
|
|
9
44
|
if (value === undefined || value === null) return null;
|
|
10
45
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
11
46
|
throw bindingError();
|
|
12
47
|
}
|
|
48
|
+
// `terminal_contract` is optional: Web adds it only for independent WorkFlow
|
|
49
|
+
// runs whose terminal must use the typed dev-work event intake (G2). Its only
|
|
50
|
+
// allowed value is the typed terminal contract version.
|
|
51
|
+
const typed = Object.hasOwn(value, "terminal_contract");
|
|
13
52
|
const keys = Object.keys(value).sort();
|
|
14
|
-
const expected =
|
|
53
|
+
const expected = typed
|
|
54
|
+
? ["action_digest", "source_ref", "terminal_contract", "workflow_id", "workflow_run_id"]
|
|
55
|
+
: ["action_digest", "source_ref", "workflow_id", "workflow_run_id"];
|
|
15
56
|
if (
|
|
16
57
|
keys.length !== expected.length ||
|
|
17
58
|
keys.some((key, index) => key !== expected[index]) ||
|
|
59
|
+
(typed && value.terminal_contract !== TERMINAL_CONTRACT_VERSION) ||
|
|
18
60
|
!WORKFLOW_UUID.test(String(value.workflow_id || "")) ||
|
|
19
61
|
!WORKFLOW_UUID.test(String(value.workflow_run_id || "")) ||
|
|
20
62
|
!DIGEST.test(String(value.action_digest || "")) ||
|
|
@@ -24,34 +66,271 @@ export function normalizeWorkflowObservationBinding(value, { provider = null } =
|
|
|
24
66
|
throw bindingError();
|
|
25
67
|
}
|
|
26
68
|
return Object.freeze({
|
|
27
|
-
workflow_id: String(value.workflow_id),
|
|
28
|
-
workflow_run_id: String(value.workflow_run_id),
|
|
69
|
+
workflow_id: String(value.workflow_id).toLowerCase(),
|
|
70
|
+
workflow_run_id: String(value.workflow_run_id).toLowerCase(),
|
|
29
71
|
action_digest: String(value.action_digest),
|
|
30
72
|
source_ref: String(value.source_ref),
|
|
73
|
+
...(typed ? { terminal_contract: TERMINAL_CONTRACT_VERSION } : {}),
|
|
31
74
|
});
|
|
32
75
|
}
|
|
33
76
|
|
|
34
77
|
export function createWorkflowObservationSender({
|
|
35
78
|
binding,
|
|
79
|
+
startIntent = null,
|
|
36
80
|
backend,
|
|
37
81
|
session,
|
|
38
82
|
apiOrigin,
|
|
39
83
|
fetchImpl = globalThis.fetch,
|
|
40
84
|
now = () => new Date(),
|
|
41
85
|
}) {
|
|
42
|
-
|
|
43
|
-
|
|
86
|
+
let normalizedBinding = normalizeWorkflowObservationBinding(binding, { provider: backend });
|
|
87
|
+
const normalizedIntent = normalizeWorkflowStartIntent(startIntent, { provider: backend });
|
|
88
|
+
if (!normalizedBinding && !normalizedIntent) return null;
|
|
89
|
+
if (normalizedBinding && normalizedIntent) throw bindingError();
|
|
44
90
|
if (backend !== "codex" && backend !== "claude") {
|
|
45
91
|
throw new Error("WorkFlow observation provider must be codex or claude.");
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
let providerSessionId = null;
|
|
49
95
|
let terminal = null;
|
|
96
|
+
let startPromise = null;
|
|
97
|
+
let admittedAtMs = null;
|
|
98
|
+
|
|
99
|
+
async function post(path, body, attempts) {
|
|
100
|
+
let response = null;
|
|
101
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
102
|
+
try {
|
|
103
|
+
response = await fetchImpl(
|
|
104
|
+
webApiUrl(path, { webOrigin: apiOrigin, apiBaseUrl: session.api_base_url }),
|
|
105
|
+
{
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: webApiHeaders(
|
|
108
|
+
{ token: session.token },
|
|
109
|
+
{ "Content-Type": "application/json" },
|
|
110
|
+
),
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
// Network failure or timeout: the idempotency key makes a resend safe.
|
|
117
|
+
response = null;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (response.status < 500) break;
|
|
121
|
+
}
|
|
122
|
+
if (!response) return { response: null, payload: null };
|
|
123
|
+
const payload = await response.json().catch(() => null);
|
|
124
|
+
return { response, payload };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function startFromEvent(eventType) {
|
|
128
|
+
if (!session?.token || !session?.api_base_url) {
|
|
129
|
+
return { ok: false, reason_code: "device_session_unavailable" };
|
|
130
|
+
}
|
|
131
|
+
const { response, payload } = await post(
|
|
132
|
+
WORKFLOW_START_PATH,
|
|
133
|
+
{ provider: backend, event_type: eventType, ...normalizedIntent },
|
|
134
|
+
START_ATTEMPTS,
|
|
135
|
+
);
|
|
136
|
+
if (!response) return { ok: false, reason_code: "workflow_start_service_unavailable" };
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
reason_code: httpReasonCode(response.status, "workflow_start_not_admitted"),
|
|
141
|
+
http_status: response.status,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const invalid = { ok: false, reason_code: "invalid_workflow_start_response" };
|
|
145
|
+
const admissionTime = Date.parse(payload?.admitted_at);
|
|
146
|
+
if (!Number.isFinite(admissionTime)) return invalid;
|
|
147
|
+
const runId = lowerUuid(payload?.workflow_run_id);
|
|
148
|
+
if (!runId || lowerUuid(payload?.workflow_id) !== normalizedIntent.workflow_id) return invalid;
|
|
149
|
+
if (payload.status === "already_recorded") {
|
|
150
|
+
return payload.workflow_observation_binding === null
|
|
151
|
+
? { ok: true, alreadyRecorded: true, workflowRunId: runId }
|
|
152
|
+
: invalid;
|
|
153
|
+
}
|
|
154
|
+
if (payload.status !== "observation_pending") return invalid;
|
|
155
|
+
let admitted;
|
|
156
|
+
try {
|
|
157
|
+
admitted = normalizeWorkflowObservationBinding(
|
|
158
|
+
payload.workflow_observation_binding,
|
|
159
|
+
{ provider: backend },
|
|
160
|
+
);
|
|
161
|
+
} catch {
|
|
162
|
+
return invalid;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
admitted?.workflow_id !== normalizedIntent.workflow_id ||
|
|
166
|
+
admitted.source_ref !== normalizedIntent.source_ref ||
|
|
167
|
+
admitted.workflow_run_id !== runId
|
|
168
|
+
) return invalid;
|
|
169
|
+
normalizedBinding = admitted;
|
|
170
|
+
admittedAtMs = admissionTime;
|
|
171
|
+
return { ok: true, alreadyRecorded: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function flushStarted() {
|
|
175
|
+
const started = startPromise ? await startPromise : null;
|
|
176
|
+
if (!started) {
|
|
177
|
+
return intentState("failed", null, { reason_code: "provider_start_event_missing" });
|
|
178
|
+
}
|
|
179
|
+
if (!started.ok) {
|
|
180
|
+
const { ok: _ok, ...reason } = started;
|
|
181
|
+
return intentState("failed", null, reason);
|
|
182
|
+
}
|
|
183
|
+
if (started.alreadyRecorded) {
|
|
184
|
+
return intentState("already_recorded", started.workflowRunId);
|
|
185
|
+
}
|
|
186
|
+
if (!terminal) {
|
|
187
|
+
return observationState("failed", normalizedBinding, {
|
|
188
|
+
reason_code: "provider_terminal_event_missing",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// A very short provider turn can finish before the start RPC commits.
|
|
192
|
+
// For a new run, record when the terminal event is submitted after
|
|
193
|
+
// admission; the event itself remains the real provider event.
|
|
194
|
+
const observedAt = new Date(Math.max(now().getTime(), admittedAtMs + 1)).toISOString();
|
|
195
|
+
return sendTypedTerminal({
|
|
196
|
+
idempotencyKey: terminalIdempotencyKey(normalizedIntent, normalizedBinding),
|
|
197
|
+
observedAt,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The typed terminal intake takes one flat 9-key body. It is used for runs
|
|
202
|
+
// started from a start intent and for Web-bound runs whose binding declares
|
|
203
|
+
// `terminal_contract` (independent_workflow_terminal_observation.v1).
|
|
204
|
+
async function sendTypedTerminal({ idempotencyKey, observedAt }) {
|
|
205
|
+
const body = {
|
|
206
|
+
provider: backend,
|
|
207
|
+
event_type: terminal.eventType,
|
|
208
|
+
outcome: terminal.outcome,
|
|
209
|
+
workflow_id: normalizedBinding.workflow_id,
|
|
210
|
+
workflow_run_id: normalizedBinding.workflow_run_id,
|
|
211
|
+
action_digest: normalizedBinding.action_digest,
|
|
212
|
+
source_ref: normalizedBinding.source_ref,
|
|
213
|
+
idempotency_key: idempotencyKey,
|
|
214
|
+
observed_at: observedAt,
|
|
215
|
+
};
|
|
216
|
+
const { response, payload } = await post(WORKFLOW_TERMINAL_PATH, body, TERMINAL_ATTEMPTS);
|
|
217
|
+
if (!response) {
|
|
218
|
+
return observationState("failed", normalizedBinding, {
|
|
219
|
+
reason_code: "observation_service_unavailable",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
return observationState("failed", normalizedBinding, {
|
|
224
|
+
reason_code: httpReasonCode(response.status, "observation_not_accepted"),
|
|
225
|
+
http_status: response.status,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (!validTerminalReceipt(payload, normalizedBinding)) {
|
|
229
|
+
return observationState("failed", normalizedBinding, {
|
|
230
|
+
reason_code: "invalid_observation_receipt",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return observationState("accepted", normalizedBinding, {
|
|
234
|
+
observation_id: payload.observation_id.toLowerCase(),
|
|
235
|
+
receipt_outcome: payload.outcome,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function flushBound() {
|
|
240
|
+
if (!terminal) {
|
|
241
|
+
return observationState("failed", normalizedBinding, {
|
|
242
|
+
reason_code: "provider_terminal_event_missing",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (!providerSessionId) {
|
|
246
|
+
return observationState("failed", normalizedBinding, {
|
|
247
|
+
reason_code: "provider_session_identity_missing",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (!session?.token || !session?.api_base_url) {
|
|
251
|
+
return observationState("failed", normalizedBinding, {
|
|
252
|
+
reason_code: "device_session_unavailable",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (normalizedBinding.terminal_contract === TERMINAL_CONTRACT_VERSION) {
|
|
257
|
+
return sendTypedTerminal({
|
|
258
|
+
idempotencyKey: boundTerminalIdempotencyKey(normalizedBinding),
|
|
259
|
+
observedAt: terminal.observedAt,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Legacy bindings (no terminal_contract) keep the original observations route.
|
|
264
|
+
const body = {
|
|
265
|
+
action_digest: normalizedBinding.action_digest,
|
|
266
|
+
source_ref: normalizedBinding.source_ref,
|
|
267
|
+
observed_at: terminal.observedAt,
|
|
268
|
+
event: {
|
|
269
|
+
provider: backend,
|
|
270
|
+
type: terminal.eventType,
|
|
271
|
+
outcome: terminal.outcome,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
const { response, payload } = await post(
|
|
275
|
+
`workflows/${encodeURIComponent(normalizedBinding.workflow_id)}/runs/${encodeURIComponent(normalizedBinding.workflow_run_id)}/observations`,
|
|
276
|
+
body,
|
|
277
|
+
2,
|
|
278
|
+
);
|
|
279
|
+
if (!response) {
|
|
280
|
+
return observationState("failed", normalizedBinding, {
|
|
281
|
+
reason_code: "observation_service_unavailable",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
return observationState("failed", normalizedBinding, {
|
|
286
|
+
reason_code: httpReasonCode(response.status, "observation_not_accepted", { legacy: true }),
|
|
287
|
+
http_status: response.status,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const receipt = payload?.receipt;
|
|
291
|
+
if (!validReceipt(receipt, normalizedBinding)) {
|
|
292
|
+
return observationState("failed", normalizedBinding, {
|
|
293
|
+
reason_code: "invalid_observation_receipt",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return observationState("accepted", normalizedBinding, {
|
|
297
|
+
observation_id: receipt.observation_id,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function intentState(status, workflowRunId, extra = {}) {
|
|
302
|
+
return {
|
|
303
|
+
status,
|
|
304
|
+
workflow_id: normalizedIntent.workflow_id,
|
|
305
|
+
workflow_run_id: workflowRunId,
|
|
306
|
+
development_outcome: "unverified",
|
|
307
|
+
...extra,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
50
310
|
|
|
51
311
|
return {
|
|
312
|
+
// The Provider's own terminal outcome ("succeeded" | "failed"), or null
|
|
313
|
+
// before a terminal event. Deliverable upload is gated on "succeeded".
|
|
314
|
+
terminalOutcome() {
|
|
315
|
+
return terminal?.outcome ?? null;
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
// The binding the terminal was sent for (admitted binding for start intents).
|
|
319
|
+
binding() {
|
|
320
|
+
return normalizedBinding;
|
|
321
|
+
},
|
|
322
|
+
|
|
52
323
|
observe(event) {
|
|
53
324
|
if (!event || typeof event !== "object" || terminal) return;
|
|
54
325
|
providerSessionId ||= providerSessionIdentifier(backend, event);
|
|
326
|
+
if (normalizedIntent && providerSessionId && !startPromise) {
|
|
327
|
+
const eventType = backend === "codex" ? "thread.started" : "system.init";
|
|
328
|
+
// startFromEvent never rejects; it resolves to a typed outcome.
|
|
329
|
+
startPromise = startFromEvent(eventType).catch(() => ({
|
|
330
|
+
ok: false,
|
|
331
|
+
reason_code: "workflow_start_service_unavailable",
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
55
334
|
const terminalEvent = terminalProviderState(backend, event);
|
|
56
335
|
if (!terminalEvent) return;
|
|
57
336
|
terminal = {
|
|
@@ -60,88 +339,72 @@ export function createWorkflowObservationSender({
|
|
|
60
339
|
};
|
|
61
340
|
},
|
|
62
341
|
|
|
342
|
+
// Never rejects: an observation failure must not change the Provider
|
|
343
|
+
// result or block the run from completing.
|
|
63
344
|
async flush() {
|
|
64
|
-
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
reason_code: "
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
if (!session?.token || !session?.api_base_url) {
|
|
75
|
-
return observationState("failed", normalizedBinding, {
|
|
76
|
-
reason_code: "device_session_unavailable",
|
|
77
|
-
});
|
|
345
|
+
try {
|
|
346
|
+
return normalizedIntent ? await flushStarted() : await flushBound();
|
|
347
|
+
} catch {
|
|
348
|
+
return normalizedBinding
|
|
349
|
+
? observationState("failed", normalizedBinding, {
|
|
350
|
+
reason_code: "observation_sender_error",
|
|
351
|
+
})
|
|
352
|
+
: intentState("failed", null, { reason_code: "observation_sender_error" });
|
|
78
353
|
}
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
79
357
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
outcome: terminal.outcome,
|
|
88
|
-
},
|
|
89
|
-
};
|
|
358
|
+
function httpReasonCode(status, fallback, { legacy = false } = {}) {
|
|
359
|
+
if (status === 401 || status === 403) return "device_or_source_not_authorized";
|
|
360
|
+
if (status === 404 || status === 410) return "workflow_binding_unavailable";
|
|
361
|
+
// The pre-bound route keeps its established reason codes.
|
|
362
|
+
if (status === 409 && !legacy) return "workflow_run_not_current";
|
|
363
|
+
return fallback;
|
|
364
|
+
}
|
|
90
365
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
apiBaseUrl: session.api_base_url,
|
|
100
|
-
},
|
|
101
|
-
),
|
|
102
|
-
{
|
|
103
|
-
method: "POST",
|
|
104
|
-
headers: webApiHeaders(
|
|
105
|
-
{ token: session.token },
|
|
106
|
-
{ "Content-Type": "application/json" },
|
|
107
|
-
),
|
|
108
|
-
body: JSON.stringify(body),
|
|
109
|
-
signal: AbortSignal.timeout(10_000),
|
|
110
|
-
},
|
|
111
|
-
);
|
|
112
|
-
} catch {
|
|
113
|
-
if (attempt === 2) {
|
|
114
|
-
return observationState("failed", normalizedBinding, {
|
|
115
|
-
reason_code: "observation_service_unavailable",
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (response.status < 500 || attempt === 2) break;
|
|
121
|
-
}
|
|
366
|
+
function terminalIdempotencyKey(intent, binding) {
|
|
367
|
+
// Stable per started run so a retried or re-flushed terminal event replays
|
|
368
|
+
// the same observation instead of recording a second one.
|
|
369
|
+
const digest = createHash("sha256")
|
|
370
|
+
.update(`${intent.idempotency_key}\u0000${binding.workflow_run_id}`)
|
|
371
|
+
.digest("hex");
|
|
372
|
+
return `terminal:${digest}`;
|
|
373
|
+
}
|
|
122
374
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
375
|
+
function boundTerminalIdempotencyKey(binding) {
|
|
376
|
+
// A Web-bound run has no start intent key; the binding identity is stable for
|
|
377
|
+
// the run attempt, so a resend replays the same observation.
|
|
378
|
+
const digest = createHash("sha256")
|
|
379
|
+
.update(
|
|
380
|
+
`bound\u0000${binding.workflow_run_id}\u0000${binding.action_digest}\u0000${binding.source_ref}`,
|
|
381
|
+
)
|
|
382
|
+
.digest("hex");
|
|
383
|
+
return `terminal:${digest}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function validTerminalReceipt(receipt, binding) {
|
|
387
|
+
const keys = [
|
|
388
|
+
"action_digest", "contract_version", "observation_id",
|
|
389
|
+
"outcome", "workflow_id", "workflow_run_id",
|
|
390
|
+
];
|
|
391
|
+
return Boolean(
|
|
392
|
+
receipt &&
|
|
393
|
+
typeof receipt === "object" &&
|
|
394
|
+
!Array.isArray(receipt) &&
|
|
395
|
+
Object.keys(receipt).length === keys.length &&
|
|
396
|
+
keys.every((key) => Object.hasOwn(receipt, key)) &&
|
|
397
|
+
receipt.contract_version === TERMINAL_CONTRACT_VERSION &&
|
|
398
|
+
(receipt.outcome === "recorded" || receipt.outcome === "idempotent_replay") &&
|
|
399
|
+
lowerUuid(receipt.workflow_id) === binding.workflow_id &&
|
|
400
|
+
lowerUuid(receipt.workflow_run_id) === binding.workflow_run_id &&
|
|
401
|
+
receipt.action_digest === binding.action_digest &&
|
|
402
|
+
lowerUuid(receipt.observation_id) !== null,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function lowerUuid(value) {
|
|
407
|
+
return typeof value === "string" && WORKFLOW_UUID.test(value) ? value.toLowerCase() : null;
|
|
145
408
|
}
|
|
146
409
|
|
|
147
410
|
function providerSessionIdentifier(backend, event) {
|
|
@@ -179,10 +442,9 @@ function validReceipt(receipt, binding) {
|
|
|
179
442
|
receipt &&
|
|
180
443
|
typeof receipt === "object" &&
|
|
181
444
|
receipt.accepted === true &&
|
|
182
|
-
receipt.workflow_id === binding.workflow_id &&
|
|
183
|
-
receipt.workflow_run_id === binding.workflow_run_id &&
|
|
184
|
-
|
|
185
|
-
WORKFLOW_UUID.test(receipt.observation_id),
|
|
445
|
+
lowerUuid(receipt.workflow_id) === binding.workflow_id &&
|
|
446
|
+
lowerUuid(receipt.workflow_run_id) === binding.workflow_run_id &&
|
|
447
|
+
lowerUuid(receipt.observation_id) !== null,
|
|
186
448
|
);
|
|
187
449
|
}
|
|
188
450
|
|