@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.
@@ -0,0 +1,454 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { buildLocalTaskArtifactRefs } from "./local-task-artifact-contract.js";
4
+ import { webApiHeaders, webApiUrl } from "./web-api.js";
5
+
6
+ /**
7
+ * WorkFlow deliverable device upload (issue #2550 G3, backend
8
+ * contracts/workflow-device-upload.v1.json).
9
+ *
10
+ * What counts as a deliverable: only the verified files a Local Task Provider
11
+ * produced beneath the task's managed output directory (`outputs_root`), as
12
+ * collected by collectLocalTaskArtifacts() after the Provider finished. Files
13
+ * elsewhere in the workspace are never uploaded, and every byte read goes
14
+ * through the containment-checked localTaskArtifact() reader.
15
+ *
16
+ * Order (backend authorization requires a consumed, succeeded receipt):
17
+ * 1. the typed terminal was already accepted with outcome "succeeded";
18
+ * 2. POST the verified manifest to the device gateway
19
+ * `workflow-dev-work-events/deliverables` (Crew submits it to Backend);
20
+ * 3. per file: claim at functions/v1/workflow-deliverable-upload with the
21
+ * device bearer, then PUT the exact bytes to the signed upload_url.
22
+ *
23
+ * Never throws and never logs; the returned summary contains no token, signed
24
+ * URL or absolute path, so it is safe to persist in the Local Task snapshot.
25
+ */
26
+
27
+ export const WORKFLOW_DELIVERABLES_PATH = "workflow-dev-work-events/deliverables";
28
+ export const DELIVERABLE_MANIFEST_CONTRACT = "independent_workflow_deliverable_manifest.v1";
29
+ export const DEVICE_UPLOAD_CONTRACT = "workflow_device_upload.v1";
30
+ export const MAX_DELIVERABLE_BYTES = 52_428_800;
31
+ export const UPLOAD_FUNCTION_PATH = "/functions/v1/workflow-deliverable-upload";
32
+ export const UPLOAD_ENDPOINT_ENV = "SIMY_WORKFLOW_DELIVERABLE_UPLOAD_URL";
33
+
34
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
35
+ const SHA256 = /^[0-9a-f]{64}$/;
36
+ const SUPABASE_HOST = /^[a-z0-9]{20}\.supabase\.co$/;
37
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
38
+ const REQUEST_TIMEOUT_MS = 10_000;
39
+ const PUT_TIMEOUT_MS = 120_000;
40
+ // Crew may not have consumed the terminal receipt yet when the manifest
41
+ // arrives (409), and a claim returns nothing until the job exists.
42
+ const MANIFEST_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000];
43
+ const CLAIM_IDLE_DELAYS_MS = [2_000, 5_000, 10_000];
44
+ const PUT_ATTEMPTS = 3;
45
+ const PUT_RETRY_DELAY_MS = 2_000;
46
+
47
+ export async function uploadWorkflowDeliverables({
48
+ binding,
49
+ observationId,
50
+ localTaskId,
51
+ artifacts,
52
+ readArtifactBytes,
53
+ session,
54
+ apiOrigin,
55
+ fetchImpl = globalThis.fetch,
56
+ environment = process.env,
57
+ sleep = defaultSleep,
58
+ manifestRetryDelaysMs = MANIFEST_RETRY_DELAYS_MS,
59
+ claimIdleDelaysMs = CLAIM_IDLE_DELAYS_MS,
60
+ }) {
61
+ let manifest;
62
+ try {
63
+ manifest = buildLocalTaskArtifactRefs(artifacts, localTaskId);
64
+ } catch {
65
+ return summary("failed", { reason_code: "deliverable_manifest_invalid" });
66
+ }
67
+ if (manifest.length === 0) return summary("skipped", { reason_code: "no_deliverables" });
68
+ const base = { manifest_files: manifest.length };
69
+ if (!session?.token || !session?.api_base_url) {
70
+ return summary("failed", { ...base, reason_code: "device_session_unavailable" });
71
+ }
72
+
73
+ try {
74
+ const submitted = await submitManifest({
75
+ binding, observationId, manifest, session, apiOrigin, fetchImpl, sleep,
76
+ delays: manifestRetryDelaysMs,
77
+ });
78
+ if (!submitted.ok) return summary("failed", { ...base, ...submitted.reason });
79
+
80
+ const endpoint = resolveUploadEndpoint(environment?.[UPLOAD_ENDPOINT_ENV], submitted.payload);
81
+ if (!endpoint) return summary("failed", { ...base, reason_code: "upload_endpoint_unavailable" });
82
+ const orgId = firstUuid(session.organization_id, submitted.payload.org_id);
83
+ const authUserId = firstUuid(session.account_id, submitted.payload.auth_user_id);
84
+ if (!orgId || !authUserId) {
85
+ return summary("failed", { ...base, reason_code: "device_identity_unavailable" });
86
+ }
87
+
88
+ const result = await claimAndUpload({
89
+ endpoint,
90
+ claimBody: {
91
+ action: "claim",
92
+ device_token: session.token,
93
+ org_id: orgId,
94
+ auth_user_id: authUserId,
95
+ workflow_run_id: binding.workflow_run_id,
96
+ observation_id: observationId,
97
+ source_task_id: localTaskId,
98
+ },
99
+ manifest,
100
+ localTaskId,
101
+ readArtifactBytes,
102
+ fetchImpl,
103
+ sleep,
104
+ idleDelays: claimIdleDelaysMs,
105
+ });
106
+ const status =
107
+ result.failures.length === 0 && result.uploaded.length === manifest.length
108
+ ? "completed"
109
+ : result.uploaded.length > 0
110
+ ? "partial"
111
+ : "failed";
112
+ return summary(status, {
113
+ ...base,
114
+ ...(status === "completed" ? {} : { reason_code: result.reasonCode }),
115
+ uploaded: result.uploaded,
116
+ failures: result.failures,
117
+ });
118
+ } catch {
119
+ return summary("failed", { ...base, reason_code: "deliverable_upload_error" });
120
+ }
121
+ }
122
+
123
+ async function submitManifest({
124
+ binding, observationId, manifest, session, apiOrigin, fetchImpl, sleep, delays,
125
+ }) {
126
+ const body = JSON.stringify({
127
+ workflow_id: binding.workflow_id,
128
+ workflow_run_id: binding.workflow_run_id,
129
+ action_digest: binding.action_digest,
130
+ observation_id: observationId,
131
+ manifest,
132
+ });
133
+ let lastStatus = null;
134
+ for (let attempt = 0; attempt <= delays.length; attempt += 1) {
135
+ if (attempt > 0) await sleep(delays[attempt - 1]);
136
+ let response;
137
+ try {
138
+ response = await fetchImpl(
139
+ webApiUrl(WORKFLOW_DELIVERABLES_PATH, {
140
+ webOrigin: apiOrigin,
141
+ apiBaseUrl: session.api_base_url,
142
+ }),
143
+ {
144
+ method: "POST",
145
+ headers: webApiHeaders({ token: session.token }, { "Content-Type": "application/json" }),
146
+ body,
147
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
148
+ },
149
+ );
150
+ } catch {
151
+ lastStatus = null;
152
+ continue;
153
+ }
154
+ lastStatus = response.status;
155
+ // 409: receipt not consumed yet; 5xx: transient. Both are safe to resend
156
+ // because Backend treats an identical manifest as the same submission.
157
+ if (response.status === 409 || response.status >= 500) continue;
158
+ if (!response.ok) {
159
+ return { ok: false, reason: httpReason(response.status, "deliverable_manifest_rejected") };
160
+ }
161
+ const payload = await response.json().catch(() => null);
162
+ if (
163
+ !payload ||
164
+ typeof payload !== "object" ||
165
+ payload.contract_version !== DELIVERABLE_MANIFEST_CONTRACT ||
166
+ payload.accepted !== true
167
+ ) {
168
+ return { ok: false, reason: { reason_code: "invalid_deliverable_manifest_receipt" } };
169
+ }
170
+ return { ok: true, payload };
171
+ }
172
+ return {
173
+ ok: false,
174
+ reason: lastStatus === 409
175
+ ? { reason_code: "deliverable_manifest_not_admitted", http_status: 409 }
176
+ : { reason_code: "deliverable_service_unavailable" },
177
+ };
178
+ }
179
+
180
+ async function claimAndUpload({
181
+ endpoint, claimBody, manifest, localTaskId, readArtifactBytes, fetchImpl, sleep, idleDelays,
182
+ }) {
183
+ const pending = new Map(manifest.map((entry) => [entry.relative_path, entry]));
184
+ const putAttempts = new Map();
185
+ const uploaded = [];
186
+ const failures = [];
187
+ let reasonCode = null;
188
+ let idle = 0;
189
+ // Hard bound: every file may need a few claims, plus idle waits.
190
+ let claimBudget = manifest.length * (PUT_ATTEMPTS + 1) + idleDelays.length + 1;
191
+
192
+ const fail = (relativePath, code) => {
193
+ reasonCode ||= code;
194
+ failures.push(relativePath === null ? { reason_code: code } : { relative_path: relativePath, reason_code: code });
195
+ if (relativePath !== null) pending.delete(relativePath);
196
+ };
197
+
198
+ while (pending.size > 0 && claimBudget > 0) {
199
+ claimBudget -= 1;
200
+ const claim = await postClaim(endpoint, claimBody, fetchImpl);
201
+ if (claim.kind === "fatal") {
202
+ reasonCode ||= claim.reasonCode;
203
+ break;
204
+ }
205
+ if (claim.kind === "idle") {
206
+ // No pending file for this device/run/receipt/task yet (or transient).
207
+ if (idle >= idleDelays.length) {
208
+ reasonCode ||= claim.reasonCode;
209
+ break;
210
+ }
211
+ await sleep(idleDelays[idle]);
212
+ idle += 1;
213
+ continue;
214
+ }
215
+
216
+ const job = claim.payload;
217
+ if (!isSafeRelativePath(job.source_relative_path)) {
218
+ // Path traversal or an absolute path is never read, even if listed.
219
+ fail(null, "claim_path_rejected");
220
+ continue;
221
+ }
222
+ const entry = pending.get(job.source_relative_path);
223
+ if (!entry) {
224
+ fail(null, "claim_unknown_file");
225
+ continue;
226
+ }
227
+ if (job.size_bytes > MAX_DELIVERABLE_BYTES || entry.size_bytes > MAX_DELIVERABLE_BYTES) {
228
+ fail(entry.relative_path, "file_too_large");
229
+ continue;
230
+ }
231
+ if (
232
+ job.source_task_id !== localTaskId ||
233
+ (job.source_artifact_id !== undefined && job.source_artifact_id !== entry.id) ||
234
+ job.size_bytes !== entry.size_bytes ||
235
+ job.sha256 !== String(entry.sha256).toLowerCase() ||
236
+ job.mime_type !== entry.mime_type
237
+ ) {
238
+ fail(entry.relative_path, "claim_manifest_mismatch");
239
+ continue;
240
+ }
241
+ if (!trustedUploadUrl(job.upload_url, endpoint)) {
242
+ fail(entry.relative_path, "untrusted_upload_url");
243
+ continue;
244
+ }
245
+
246
+ let bytes;
247
+ try {
248
+ bytes = await readArtifactBytes(entry.id);
249
+ } catch {
250
+ bytes = null;
251
+ }
252
+ if (
253
+ !bytes ||
254
+ bytes.length !== job.size_bytes ||
255
+ createHash("sha256").update(bytes).digest("hex") !== job.sha256
256
+ ) {
257
+ fail(entry.relative_path, "local_file_changed");
258
+ continue;
259
+ }
260
+
261
+ const put = await putWithRetry(job, bytes, fetchImpl, sleep, () => {
262
+ const used = (putAttempts.get(entry.relative_path) || 0) + 1;
263
+ putAttempts.set(entry.relative_path, used);
264
+ return used;
265
+ });
266
+ if (put.kind === "uploaded") {
267
+ uploaded.push({ artifact_id: put.artifactId, relative_path: entry.relative_path });
268
+ pending.delete(entry.relative_path);
269
+ } else if (put.kind === "reclaim") {
270
+ // The signed URL or lease expired; the next claim reissues the same
271
+ // stable artifact_id unless attempts are exhausted.
272
+ if ((putAttempts.get(entry.relative_path) || 0) >= PUT_ATTEMPTS) {
273
+ fail(entry.relative_path, put.reasonCode);
274
+ }
275
+ } else {
276
+ fail(entry.relative_path, put.reasonCode);
277
+ }
278
+ }
279
+ for (const relativePath of pending.keys()) {
280
+ failures.push({ relative_path: relativePath, reason_code: reasonCode || "claim_unavailable" });
281
+ }
282
+ if (failures.length && !reasonCode) reasonCode = failures[0].reason_code;
283
+ return { uploaded, failures, reasonCode };
284
+ }
285
+
286
+ async function postClaim(endpoint, claimBody, fetchImpl) {
287
+ let response;
288
+ try {
289
+ response = await fetchImpl(endpoint.href, {
290
+ method: "POST",
291
+ headers: { "Content-Type": "application/json" },
292
+ body: JSON.stringify(claimBody),
293
+ redirect: "error",
294
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
295
+ });
296
+ } catch {
297
+ return { kind: "idle", reasonCode: "upload_service_unavailable" };
298
+ }
299
+ if (response.status === 403) return { kind: "idle", reasonCode: "claim_unavailable" };
300
+ if (response.status >= 500) return { kind: "idle", reasonCode: "upload_service_unavailable" };
301
+ if (!response.ok) return { kind: "fatal", reasonCode: "claim_rejected" };
302
+ const payload = await response.json().catch(() => null);
303
+ if (!validClaim(payload)) return { kind: "fatal", reasonCode: "invalid_claim_response" };
304
+ return { kind: "claimed", payload };
305
+ }
306
+
307
+ async function putWithRetry(job, bytes, fetchImpl, sleep, countAttempt) {
308
+ while (true) {
309
+ const attempt = countAttempt();
310
+ let response = null;
311
+ try {
312
+ response = await fetchImpl(job.upload_url, {
313
+ method: "PUT",
314
+ headers: {
315
+ "Content-Type": job.mime_type,
316
+ "Content-Length": String(bytes.length),
317
+ },
318
+ body: bytes,
319
+ redirect: "error",
320
+ signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
321
+ });
322
+ } catch {
323
+ response = null;
324
+ }
325
+ if (response?.ok) {
326
+ const payload = await response.json().catch(() => null);
327
+ if (
328
+ payload?.contract_version === DEVICE_UPLOAD_CONTRACT &&
329
+ typeof payload.artifact_id === "string" &&
330
+ payload.artifact_id.toLowerCase() === job.artifact_id.toLowerCase()
331
+ ) {
332
+ return { kind: "uploaded", artifactId: job.artifact_id.toLowerCase() };
333
+ }
334
+ return { kind: "failed", reasonCode: "invalid_upload_response" };
335
+ }
336
+ if (response && response.status === 403) {
337
+ return { kind: "reclaim", reasonCode: "upload_authorization_expired" };
338
+ }
339
+ if (response && response.status === 413) return { kind: "failed", reasonCode: "file_too_large" };
340
+ if (response && response.status === 422) {
341
+ return { kind: "failed", reasonCode: "file_does_not_match_manifest" };
342
+ }
343
+ if (response && response.status < 500) {
344
+ return { kind: "failed", reasonCode: `upload_rejected_${response.status}` };
345
+ }
346
+ // Network failure or 5xx: the signed URL is still valid for the lease, and
347
+ // the gateway accepts identical immutable bytes on a repeat.
348
+ if (attempt >= PUT_ATTEMPTS) return { kind: "failed", reasonCode: "upload_failed_after_retries" };
349
+ await sleep(PUT_RETRY_DELAY_MS);
350
+ }
351
+ }
352
+
353
+ function validClaim(value) {
354
+ return Boolean(
355
+ value &&
356
+ typeof value === "object" &&
357
+ value.contract_version === DEVICE_UPLOAD_CONTRACT &&
358
+ typeof value.artifact_id === "string" &&
359
+ UUID.test(value.artifact_id) &&
360
+ typeof value.source_relative_path === "string" &&
361
+ typeof value.source_task_id === "string" &&
362
+ Number.isSafeInteger(value.size_bytes) &&
363
+ value.size_bytes > 0 &&
364
+ typeof value.sha256 === "string" &&
365
+ SHA256.test(value.sha256) &&
366
+ typeof value.mime_type === "string" &&
367
+ value.mime_type.length > 0 &&
368
+ typeof value.upload_url === "string",
369
+ );
370
+ }
371
+
372
+ /** Mirrors Backend's source_relative_path CHECK constraint. */
373
+ export function isSafeRelativePath(value) {
374
+ return (
375
+ typeof value === "string" &&
376
+ value.length >= 1 &&
377
+ value.length <= 2048 &&
378
+ !/[\u0000-\u001f\u007f\\]/.test(value) &&
379
+ !/(^|\/)\.\.?(\/|$)/.test(value) &&
380
+ !/^(\/|~\/|[A-Za-z]:)/.test(value) &&
381
+ !value.includes("//") &&
382
+ !value.endsWith("/")
383
+ );
384
+ }
385
+
386
+ /**
387
+ * The device bearer is sent only to the Backend upload function. An operator
388
+ * override (env) may be any https URL, or loopback http for local Supabase; an
389
+ * endpoint suggested by the Web/Crew response must be a hosted Supabase
390
+ * project URL. Both must be the exact function path with no credentials,
391
+ * query or fragment.
392
+ */
393
+ export function resolveUploadEndpoint(override, payload) {
394
+ if (typeof override === "string" && override.trim()) {
395
+ const url = parseEndpoint(override.trim());
396
+ if (!url) return null;
397
+ if (url.protocol === "https:") return url;
398
+ return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname) ? url : null;
399
+ }
400
+ const suggested = typeof payload?.upload_endpoint === "string"
401
+ ? parseEndpoint(payload.upload_endpoint)
402
+ : null;
403
+ return suggested && suggested.protocol === "https:" && SUPABASE_HOST.test(suggested.hostname) &&
404
+ suggested.port === ""
405
+ ? suggested
406
+ : null;
407
+ }
408
+
409
+ function parseEndpoint(value) {
410
+ let url;
411
+ try {
412
+ url = new URL(value);
413
+ } catch {
414
+ return null;
415
+ }
416
+ if (url.username || url.password || url.search || url.hash) return null;
417
+ return url.pathname === UPLOAD_FUNCTION_PATH ? url : null;
418
+ }
419
+
420
+ function trustedUploadUrl(value, endpoint) {
421
+ let url;
422
+ try {
423
+ url = new URL(value);
424
+ } catch {
425
+ return false;
426
+ }
427
+ return (
428
+ url.origin === endpoint.origin &&
429
+ url.pathname === endpoint.pathname &&
430
+ !url.username &&
431
+ !url.password
432
+ );
433
+ }
434
+
435
+ function firstUuid(...values) {
436
+ for (const value of values) {
437
+ if (typeof value === "string" && UUID.test(value)) return value.toLowerCase();
438
+ }
439
+ return null;
440
+ }
441
+
442
+ function httpReason(status, fallback) {
443
+ if (status === 401 || status === 403) return { reason_code: "device_or_source_not_authorized", http_status: status };
444
+ if (status === 404 || status === 410) return { reason_code: "workflow_binding_unavailable", http_status: status };
445
+ return { reason_code: fallback, http_status: status };
446
+ }
447
+
448
+ function summary(status, extra = {}) {
449
+ return { status, contract_version: DEVICE_UPLOAD_CONTRACT, ...extra };
450
+ }
451
+
452
+ function defaultSleep(ms) {
453
+ return new Promise((resolve) => setTimeout(resolve, ms));
454
+ }