@awak-app/simy-cli 0.5.34 → 0.5.36

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.
@@ -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 = ["action_digest", "source_ref", "workflow_id", "workflow_run_id"];
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
- const normalizedBinding = normalizeWorkflowObservationBinding(binding, { provider: backend });
43
- if (!normalizedBinding) return null;
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
- if (!terminal) {
65
- return observationState("failed", normalizedBinding, {
66
- reason_code: "provider_terminal_event_missing",
67
- });
68
- }
69
- if (!providerSessionId) {
70
- return observationState("failed", normalizedBinding, {
71
- reason_code: "provider_session_identity_missing",
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
- const body = {
81
- action_digest: normalizedBinding.action_digest,
82
- source_ref: normalizedBinding.source_ref,
83
- observed_at: terminal.observedAt,
84
- event: {
85
- provider: backend,
86
- type: terminal.eventType,
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
- let response;
92
- for (let attempt = 1; attempt <= 2; attempt += 1) {
93
- try {
94
- response = await fetchImpl(
95
- webApiUrl(
96
- `workflows/${encodeURIComponent(normalizedBinding.workflow_id)}/runs/${encodeURIComponent(normalizedBinding.workflow_run_id)}/observations`,
97
- {
98
- webOrigin: apiOrigin,
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
- const payload = await response.json().catch(() => null);
124
- if (!response.ok) {
125
- return observationState("failed", normalizedBinding, {
126
- reason_code: response.status === 401 || response.status === 403
127
- ? "device_or_source_not_authorized"
128
- : response.status === 404 || response.status === 410
129
- ? "workflow_binding_unavailable"
130
- : "observation_not_accepted",
131
- http_status: response.status,
132
- });
133
- }
134
- const receipt = payload?.receipt;
135
- if (!validReceipt(receipt, normalizedBinding)) {
136
- return observationState("failed", normalizedBinding, {
137
- reason_code: "invalid_observation_receipt",
138
- });
139
- }
140
- return observationState("accepted", normalizedBinding, {
141
- observation_id: receipt.observation_id,
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
- typeof receipt.observation_id === "string" &&
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