@awak-app/simy-cli 0.2.3 → 0.4.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/README.md +145 -1
- package/package.json +14 -5
- package/src/agent.js +740 -37
- package/src/bounded-local-task-subtask-pool.js +708 -0
- package/src/bounded-local-task-subtasks-contract.js +1189 -0
- package/src/cli-contract.js +42 -3
- package/src/console/app.js +212 -90
- package/src/desktop-executor.js +21 -2
- package/src/durable-local-task-steps-contract.js +1256 -0
- package/src/durable-local-task-worker.js +2607 -0
- package/src/execution-capability-contract.js +116 -0
- package/src/execution-guardrail.js +69 -12
- package/src/index.js +22 -7
- package/src/local-attachments.js +94 -113
- package/src/local-task-artifact-contract.js +266 -0
- package/src/local-task-attachment-store.js +447 -0
- package/src/local-task-file-capabilities.js +738 -0
- package/src/local-task-scenario-packs.js +681 -0
- package/src/local-task.js +1137 -0
- package/src/orchestrator/audit.js +42 -1
- package/src/orchestrator/loop.js +29 -3
- package/src/repository-inventory.js +37 -1
- package/src/runner.js +44 -0
- package/src/shutdown.js +63 -0
- package/src/sqm/bundle-store.js +249 -0
- package/src/sqm/canonical.js +45 -0
- package/src/sqm/checkers.js +299 -0
- package/src/sqm/command.js +149 -0
- package/src/sqm/evidence-client.js +12 -0
- package/src/sqm/index.js +141 -0
- package/src/sqm/proof.js +154 -0
- package/src/sqm/repository.js +163 -0
- package/src/sqm/session.js +58 -0
- package/src/sqm/validation.js +305 -0
- package/src/workspace-context.js +40 -7
package/src/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
applyLocalHumanDecision,
|
|
@@ -33,7 +34,9 @@ import { isRequestOriginAllowed, resolveWebOrigin } from "./web-origin.js";
|
|
|
33
34
|
import {
|
|
34
35
|
cleanupExpiredRuns,
|
|
35
36
|
cleanupRunAttachments,
|
|
37
|
+
prepareRunAttachments,
|
|
36
38
|
stageRunAttachments,
|
|
39
|
+
stagePreparedRunAttachments,
|
|
37
40
|
referenceLocalAttachmentPaths,
|
|
38
41
|
} from "./local-attachments.js";
|
|
39
42
|
import { discoverWorkspace } from "./workspace-context.js";
|
|
@@ -63,6 +66,7 @@ import {
|
|
|
63
66
|
mergeRepositoryInventory,
|
|
64
67
|
readRepositoryInventory,
|
|
65
68
|
scanGitRepositories,
|
|
69
|
+
verifyRepositoryIdentity,
|
|
66
70
|
writeRepositoryInventory,
|
|
67
71
|
} from "./repository-inventory.js";
|
|
68
72
|
import {
|
|
@@ -80,6 +84,37 @@ import {
|
|
|
80
84
|
classifyExecutionRequirement,
|
|
81
85
|
EXECUTION_GUARDRAIL_POLICY_VERSION,
|
|
82
86
|
} from "./execution-guardrail.js";
|
|
87
|
+
import {
|
|
88
|
+
createLocalTask,
|
|
89
|
+
createLocalTaskId,
|
|
90
|
+
createLocalTaskRequestFingerprint,
|
|
91
|
+
hashLocalTaskIdempotencyKey,
|
|
92
|
+
localTaskArtifact,
|
|
93
|
+
localTaskSnapshot,
|
|
94
|
+
LocalTaskRegistry,
|
|
95
|
+
localTasksRoot,
|
|
96
|
+
persistLocalTask,
|
|
97
|
+
prepareLocalTaskWorkspace,
|
|
98
|
+
restoreLocalTasks,
|
|
99
|
+
startLocalTask,
|
|
100
|
+
stopLocalTask,
|
|
101
|
+
} from "./local-task.js";
|
|
102
|
+
import {
|
|
103
|
+
createDurableLocalTaskApi,
|
|
104
|
+
createDurableLocalTaskWorker,
|
|
105
|
+
} from "./durable-local-task-worker.js";
|
|
106
|
+
import { createLocalTaskAttachmentStore } from "./local-task-attachment-store.js";
|
|
107
|
+
import {
|
|
108
|
+
LOCAL_TASK_FILE_CAPABILITY_REGISTRY,
|
|
109
|
+
LOCAL_TASK_FILE_CAPABILITY_VERSION,
|
|
110
|
+
LocalTaskFileCapabilityError,
|
|
111
|
+
localTaskFileErrorPayload,
|
|
112
|
+
} from "./local-task-file-capabilities.js";
|
|
113
|
+
import {
|
|
114
|
+
LocalTaskScenarioPackError,
|
|
115
|
+
normalizeLocalTaskScenarioPack,
|
|
116
|
+
validateLocalTaskScenarioInputs,
|
|
117
|
+
} from "./local-task-scenario-packs.js";
|
|
83
118
|
|
|
84
119
|
const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
85
120
|
|
|
@@ -95,10 +130,25 @@ export async function startAgent({
|
|
|
95
130
|
const apiOrigin = resolveWebOrigin(webOrigin);
|
|
96
131
|
const registry = new LocalRunRegistry();
|
|
97
132
|
const directRegistry = new DirectTaskRegistry();
|
|
133
|
+
const localTaskRegistry = new LocalTaskRegistry();
|
|
134
|
+
const taskStorageRoot = localTasksRoot(
|
|
135
|
+
dependencies.localTasksRoot || (sessionRoot ? join(sessionRoot, "tasks") : undefined),
|
|
136
|
+
);
|
|
137
|
+
const localTaskAttachmentStore =
|
|
138
|
+
dependencies.localTaskAttachmentStore ||
|
|
139
|
+
createLocalTaskAttachmentStore({
|
|
140
|
+
root:
|
|
141
|
+
dependencies.localTaskAttachmentRoot ||
|
|
142
|
+
join(dirname(taskStorageRoot), "attachment-staging"),
|
|
143
|
+
});
|
|
144
|
+
await localTaskAttachmentStore.cleanupExpired?.();
|
|
98
145
|
const authNonce = randomBytes(16).toString("base64url");
|
|
99
146
|
let session = await readSession(apiOrigin, sessionRoot);
|
|
100
147
|
if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
|
|
101
148
|
await cleanupExpiredRuns();
|
|
149
|
+
for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
|
|
150
|
+
localTaskRegistry.create(task);
|
|
151
|
+
}
|
|
102
152
|
const workspace = dependencies.discoverWorkspace
|
|
103
153
|
? await dependencies.discoverWorkspace()
|
|
104
154
|
: await discoverWorkspace(dependencies.cwd ?? process.cwd());
|
|
@@ -117,10 +167,36 @@ export async function startAgent({
|
|
|
117
167
|
: [],
|
|
118
168
|
);
|
|
119
169
|
const installMode = updateManager?.snapshot?.()?.install_mode ?? null;
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
170
|
+
const inspectedCapabilities = await readCapabilities(dependencies);
|
|
171
|
+
const contractSourceCapabilities =
|
|
172
|
+
inspectedCapabilities?.features?.local_task_control_plane === true
|
|
173
|
+
? {
|
|
174
|
+
...inspectedCapabilities,
|
|
175
|
+
features: {
|
|
176
|
+
...inspectedCapabilities.features,
|
|
177
|
+
local_task_control_plane: daemon,
|
|
178
|
+
pipeline_local_task_artifacts:
|
|
179
|
+
daemon &&
|
|
180
|
+
inspectedCapabilities.features.pipeline_local_task_artifacts === true,
|
|
181
|
+
durable_local_task_attachments: daemon,
|
|
182
|
+
durable_local_task_steps:
|
|
183
|
+
daemon &&
|
|
184
|
+
inspectedCapabilities.features.durable_local_task_steps === true,
|
|
185
|
+
bounded_local_task_subtasks:
|
|
186
|
+
daemon &&
|
|
187
|
+
inspectedCapabilities.features.bounded_local_task_subtasks === true,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
: inspectedCapabilities;
|
|
191
|
+
let availableCapabilities;
|
|
192
|
+
const refreshAvailableCapabilities = () => {
|
|
193
|
+
availableCapabilities = withCliContract(contractSourceCapabilities, installMode, {
|
|
194
|
+
authenticated:
|
|
195
|
+
isSessionValid(session, Date.now(), apiOrigin) && Boolean(session?.device_id),
|
|
196
|
+
});
|
|
197
|
+
return availableCapabilities;
|
|
198
|
+
};
|
|
199
|
+
refreshAvailableCapabilities();
|
|
124
200
|
const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
|
|
125
201
|
const runOptions = dependencies.runOptions || {};
|
|
126
202
|
const repositoryScanRoot = resolve(
|
|
@@ -134,6 +210,7 @@ export async function startAgent({
|
|
|
134
210
|
let port = requestedPort;
|
|
135
211
|
let heartbeatTimer = null;
|
|
136
212
|
let heartbeatInFlight = null;
|
|
213
|
+
let durableLocalTaskWorker = null;
|
|
137
214
|
|
|
138
215
|
const restoreRemoteRuns = async () => {
|
|
139
216
|
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
|
|
@@ -165,15 +242,21 @@ export async function startAgent({
|
|
|
165
242
|
const heartbeat = () => {
|
|
166
243
|
if (!isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
|
|
167
244
|
if (heartbeatInFlight) return heartbeatInFlight;
|
|
168
|
-
heartbeatInFlight =
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
245
|
+
heartbeatInFlight = Promise.resolve()
|
|
246
|
+
.then(() =>
|
|
247
|
+
heartbeatDevice({
|
|
248
|
+
apiOrigin,
|
|
249
|
+
apiBaseUrl: session.api_base_url,
|
|
250
|
+
token: session.token,
|
|
251
|
+
port,
|
|
252
|
+
capabilities: refreshAvailableCapabilities(),
|
|
253
|
+
repoInventory: repositoryInventory,
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
.then(async () => {
|
|
257
|
+
if (durableLocalTaskWorker) await durableLocalTaskWorker.poll();
|
|
258
|
+
return true;
|
|
259
|
+
})
|
|
177
260
|
.finally(() => {
|
|
178
261
|
heartbeatInFlight = null;
|
|
179
262
|
});
|
|
@@ -234,12 +317,51 @@ export async function startAgent({
|
|
|
234
317
|
};
|
|
235
318
|
|
|
236
319
|
const ensureRepositoryIndexed = async (repository) => {
|
|
320
|
+
const verifyIdentity =
|
|
321
|
+
dependencies.verifyRepositoryIdentity || verifyRepositoryIdentity;
|
|
237
322
|
const indexed = findRepository(repositoryInventory, repository);
|
|
238
|
-
|
|
323
|
+
const verified = indexed
|
|
324
|
+
? await verifyIdentity(indexed, repository)
|
|
325
|
+
: null;
|
|
326
|
+
if (verified || !isRepositoryScanAuthorized()) return verified;
|
|
239
327
|
await refreshAuthorizedRepositoryInventory();
|
|
240
|
-
|
|
328
|
+
const refreshed = findRepository(repositoryInventory, repository);
|
|
329
|
+
return refreshed
|
|
330
|
+
? await verifyIdentity(refreshed, repository)
|
|
331
|
+
: null;
|
|
241
332
|
};
|
|
242
333
|
|
|
334
|
+
if (
|
|
335
|
+
daemon &&
|
|
336
|
+
availableCapabilities?.features?.local_task_control_plane === true
|
|
337
|
+
) {
|
|
338
|
+
const durableApi =
|
|
339
|
+
dependencies.durableLocalTaskApi ||
|
|
340
|
+
createDurableLocalTaskApi({
|
|
341
|
+
getConnection: () => ({
|
|
342
|
+
apiOrigin,
|
|
343
|
+
apiBaseUrl: session?.api_base_url,
|
|
344
|
+
token: session?.token,
|
|
345
|
+
}),
|
|
346
|
+
fetchImpl: dependencies.fetch || globalThis.fetch,
|
|
347
|
+
});
|
|
348
|
+
const createWorker =
|
|
349
|
+
dependencies.createDurableLocalTaskWorker ||
|
|
350
|
+
createDurableLocalTaskWorker;
|
|
351
|
+
durableLocalTaskWorker = createWorker({
|
|
352
|
+
api: durableApi,
|
|
353
|
+
registry: localTaskRegistry,
|
|
354
|
+
storageRoot: taskStorageRoot,
|
|
355
|
+
workspaceRoot: dependencies.localTaskWorkspaceRoot,
|
|
356
|
+
resolveRepository: ensureRepositoryIndexed,
|
|
357
|
+
runLocalTask: dependencies.runLocalTask || startLocalTask,
|
|
358
|
+
attachmentStore: localTaskAttachmentStore,
|
|
359
|
+
getDeviceId: () => session?.device_id || null,
|
|
360
|
+
beginWork: () => acquireUpdateWork(updateManager),
|
|
361
|
+
onError: (error) => reportDurableLocalTaskError(error, quiet),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
243
365
|
const selectRepository = async (run, repository) => {
|
|
244
366
|
if (run) ensureAcceptingNewWork(updateManager);
|
|
245
367
|
const selected = repository?.local_path
|
|
@@ -279,10 +401,14 @@ export async function startAgent({
|
|
|
279
401
|
|
|
280
402
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
281
403
|
const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
|
|
404
|
+
const startsLocalExecution =
|
|
405
|
+
url.pathname === "/v1/local-tasks/start" ||
|
|
406
|
+
url.pathname === "/v1/direct-execution/start";
|
|
282
407
|
if (
|
|
283
408
|
req.method === "POST" &&
|
|
284
|
-
agenticLoopPath.startsWith("/v1/agentic-loop") &&
|
|
285
|
-
|
|
409
|
+
((agenticLoopPath.startsWith("/v1/agentic-loop") &&
|
|
410
|
+
agenticLoopPath !== "/v1/agentic-loop/preflight") ||
|
|
411
|
+
startsLocalExecution)
|
|
286
412
|
) {
|
|
287
413
|
const releaseUpdateWork = acquireUpdateWork(updateManager);
|
|
288
414
|
if (!releaseUpdateWork) {
|
|
@@ -306,7 +432,65 @@ export async function startAgent({
|
|
|
306
432
|
return;
|
|
307
433
|
}
|
|
308
434
|
if (req.method === "GET" && url.pathname === "/v1/capabilities") {
|
|
309
|
-
json(res, 200,
|
|
435
|
+
json(res, 200, refreshAvailableCapabilities());
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (
|
|
439
|
+
req.method === "POST" &&
|
|
440
|
+
url.pathname === "/v1/local-tasks/attachments/stage"
|
|
441
|
+
) {
|
|
442
|
+
if (!req.headers.origin) {
|
|
443
|
+
json(res, 403, {
|
|
444
|
+
error: "Open SIMY Web to attach files to a Local Task.",
|
|
445
|
+
code: "local_task_attachment_web_origin_required",
|
|
446
|
+
recovery: "reattach_file",
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) {
|
|
451
|
+
json(res, 401, {
|
|
452
|
+
error: "Reconnect SIMY CLI before attaching files.",
|
|
453
|
+
code: "local_task_attachment_device_unavailable",
|
|
454
|
+
recovery: "reattach_file",
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const files = await readAttachmentStage(req);
|
|
460
|
+
const prepared = await prepareRunAttachments({ attachments: files });
|
|
461
|
+
const staged = await localTaskAttachmentStore.stage({
|
|
462
|
+
deviceId: session.device_id,
|
|
463
|
+
idempotencyKey: req.headers["idempotency-key"],
|
|
464
|
+
files: prepared,
|
|
465
|
+
});
|
|
466
|
+
res.setHeader("Cache-Control", "no-store");
|
|
467
|
+
json(res, staged.replayed ? 200 : 201, {
|
|
468
|
+
ok: true,
|
|
469
|
+
replayed: staged.replayed,
|
|
470
|
+
attachment_refs: staged.refs,
|
|
471
|
+
});
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (error instanceof LocalTaskFileCapabilityError) {
|
|
474
|
+
json(
|
|
475
|
+
res,
|
|
476
|
+
error.code === "local_task_attachment_idempotency_conflict"
|
|
477
|
+
? 409
|
|
478
|
+
: error.code === "local_task_attachment_request_too_large"
|
|
479
|
+
? 413
|
|
480
|
+
: 400,
|
|
481
|
+
localTaskFileErrorPayload(error),
|
|
482
|
+
);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
json(res, 500, {
|
|
486
|
+
error:
|
|
487
|
+
"SIMY CLI could not safely prepare these files. Keep SIMY CLI running and attach them again.",
|
|
488
|
+
code: "local_task_attachment_stage_failed",
|
|
489
|
+
recovery: "reattach_file",
|
|
490
|
+
recovery_action:
|
|
491
|
+
LOCAL_TASK_FILE_CAPABILITY_REGISTRY.recovery_actions.reattach_file,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
310
494
|
return;
|
|
311
495
|
}
|
|
312
496
|
if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/preflight") {
|
|
@@ -373,6 +557,16 @@ export async function startAgent({
|
|
|
373
557
|
device_id: typeof body.device_id === "string" ? body.device_id : null,
|
|
374
558
|
api_origin: apiOrigin,
|
|
375
559
|
api_base_url: apiBaseUrl,
|
|
560
|
+
account_id: typeof body.account_id === "string"
|
|
561
|
+
? body.account_id
|
|
562
|
+
: typeof body.auth_user_id === "string"
|
|
563
|
+
? body.auth_user_id
|
|
564
|
+
: null,
|
|
565
|
+
organization_id: typeof body.organization_id === "string"
|
|
566
|
+
? body.organization_id
|
|
567
|
+
: typeof body.org_id === "string"
|
|
568
|
+
? body.org_id
|
|
569
|
+
: null,
|
|
376
570
|
expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
|
|
377
571
|
};
|
|
378
572
|
await writeSession(apiOrigin, session, sessionRoot);
|
|
@@ -388,6 +582,363 @@ export async function startAgent({
|
|
|
388
582
|
void synchronizeAuthorizedSession();
|
|
389
583
|
return;
|
|
390
584
|
}
|
|
585
|
+
const localTaskArtifactMatch = url.pathname.match(
|
|
586
|
+
/^\/v1\/local-tasks\/([^/]+)\/artifacts\/([^/]+)$/,
|
|
587
|
+
);
|
|
588
|
+
if (req.method === "GET" && localTaskArtifactMatch) {
|
|
589
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
590
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const task = localTaskRegistry.get(decodeURIComponent(localTaskArtifactMatch[1]));
|
|
594
|
+
const artifact = await localTaskArtifact(
|
|
595
|
+
task,
|
|
596
|
+
decodeURIComponent(localTaskArtifactMatch[2]),
|
|
597
|
+
);
|
|
598
|
+
if (!task || !artifact) {
|
|
599
|
+
json(res, 404, { error: "Local Task artifact not found" });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
res.writeHead(200, {
|
|
603
|
+
"Content-Type": artifact.mime_type,
|
|
604
|
+
"Content-Length": String(artifact.size_bytes),
|
|
605
|
+
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(artifact.name)}`,
|
|
606
|
+
"X-Content-Type-Options": "nosniff",
|
|
607
|
+
});
|
|
608
|
+
createReadStream(artifact.local_path)
|
|
609
|
+
.on("error", () => {
|
|
610
|
+
if (!res.headersSent) json(res, 500, { error: "Local Task artifact is unavailable" });
|
|
611
|
+
else res.destroy();
|
|
612
|
+
})
|
|
613
|
+
.pipe(res);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const localTaskStreamMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/stream$/);
|
|
617
|
+
if (req.method === "GET" && localTaskStreamMatch) {
|
|
618
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
619
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
streamLocalTask(res, localTaskRegistry.get(decodeURIComponent(localTaskStreamMatch[1])));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const localTaskControlMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/control$/);
|
|
626
|
+
if (req.method === "POST" && localTaskControlMatch) {
|
|
627
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
628
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const task = localTaskRegistry.get(decodeURIComponent(localTaskControlMatch[1]));
|
|
632
|
+
if (!task) {
|
|
633
|
+
json(res, 404, { error: "Local Task not found" });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const body = await readJson(req);
|
|
637
|
+
if (body.action !== "stop") {
|
|
638
|
+
json(res, 400, { error: "Local Task control action must be stop" });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
await stopLocalTask(task);
|
|
642
|
+
json(res, 200, { ok: true, task: localTaskSnapshot(task) });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const localTaskMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)$/);
|
|
646
|
+
if (req.method === "GET" && url.pathname === "/v1/local-tasks/reconcile") {
|
|
647
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
648
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
let task;
|
|
652
|
+
try {
|
|
653
|
+
task = localTaskRegistry.getByIdempotencyKey(req.headers["idempotency-key"]);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (!task) {
|
|
659
|
+
res.setHeader("Cache-Control", "no-store");
|
|
660
|
+
json(res, 404, { error: "Local Task request not found" });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
res.setHeader("Cache-Control", "no-store");
|
|
664
|
+
res.setHeader("Location", `/v1/local-tasks/${task.id}`);
|
|
665
|
+
json(res, 200, { ok: true, replayed: true, task: localTaskSnapshot(task) });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (req.method === "GET" && localTaskMatch) {
|
|
669
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
670
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const task = localTaskRegistry.get(decodeURIComponent(localTaskMatch[1]));
|
|
674
|
+
if (!task) {
|
|
675
|
+
json(res, 404, { error: "Local Task not found" });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
json(res, 200, { task: localTaskSnapshot(task) });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (req.method === "POST" && url.pathname === "/v1/local-tasks/start") {
|
|
682
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
683
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const { body, attachments } = await readCodingLoopStart(req);
|
|
687
|
+
const instruction = String(body.instruction || "").trim();
|
|
688
|
+
if (!instruction) {
|
|
689
|
+
json(res, 400, { error: "instruction is required" });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const authorizationInstruction = String(
|
|
693
|
+
body.authorization_instruction || instruction,
|
|
694
|
+
).trim();
|
|
695
|
+
if (!authorizationInstruction) {
|
|
696
|
+
json(res, 400, { error: "authorization_instruction is required" });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (body.backend !== "codex" && body.backend !== "claude") {
|
|
700
|
+
json(res, 400, { error: "backend must be codex or claude" });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const idempotencyKey = String(
|
|
704
|
+
req.headers["idempotency-key"] || body.client_request_id || "",
|
|
705
|
+
).trim();
|
|
706
|
+
const guardrail = classifyExecutionRequirement(authorizationInstruction);
|
|
707
|
+
if (
|
|
708
|
+
guardrail.decision !== "direct_executor" ||
|
|
709
|
+
body.guardrail?.policy_version !== EXECUTION_GUARDRAIL_POLICY_VERSION ||
|
|
710
|
+
body.guardrail?.decision !== "direct_executor"
|
|
711
|
+
) {
|
|
712
|
+
json(res, 409, {
|
|
713
|
+
error: "This request is not authorized as a Local Task.",
|
|
714
|
+
code: "LOCAL_TASK_GUARD_REQUIRED",
|
|
715
|
+
guardrail,
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (
|
|
720
|
+
body.operation !== guardrail.operation ||
|
|
721
|
+
body.permission_mode !== guardrail.permission_mode ||
|
|
722
|
+
body.max_provider_invocations !== 1
|
|
723
|
+
) {
|
|
724
|
+
json(res, 409, {
|
|
725
|
+
error: "Local Task permissions do not match the task-routing decision.",
|
|
726
|
+
code: "LOCAL_TASK_GUARD_MISMATCH",
|
|
727
|
+
guardrail,
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const workspaceMode = String(
|
|
732
|
+
body.workspace_mode || guardrail.workspace_kind || "task_sandbox",
|
|
733
|
+
);
|
|
734
|
+
const repository = String(body.repository || "").trim() || null;
|
|
735
|
+
if (workspaceMode !== "repository" && workspaceMode !== "task_sandbox") {
|
|
736
|
+
json(res, 400, {
|
|
737
|
+
error: "workspace_mode must be task_sandbox or repository",
|
|
738
|
+
code: "unsupported_local_task_workspace",
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
let preparedAttachments;
|
|
743
|
+
try {
|
|
744
|
+
preparedAttachments = await prepareRunAttachments({
|
|
745
|
+
attachments,
|
|
746
|
+
manifest: attachments.length > 0 ? body.attachment_manifest ?? [] : undefined,
|
|
747
|
+
});
|
|
748
|
+
} catch (error) {
|
|
749
|
+
json(res, 400, {
|
|
750
|
+
error: error instanceof Error ? error.message : "attachment rejected",
|
|
751
|
+
});
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
let scenarioPack;
|
|
755
|
+
try {
|
|
756
|
+
scenarioPack = normalizeLocalTaskScenarioPack(body.scenario_pack);
|
|
757
|
+
validateLocalTaskScenarioInputs(scenarioPack, {
|
|
758
|
+
instruction,
|
|
759
|
+
attachments: preparedAttachments,
|
|
760
|
+
});
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error instanceof LocalTaskScenarioPackError) {
|
|
763
|
+
json(res, 400, {
|
|
764
|
+
error: error.message,
|
|
765
|
+
code: error.code,
|
|
766
|
+
recovery: error.recovery,
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
let requestFingerprint;
|
|
773
|
+
try {
|
|
774
|
+
requestFingerprint = createLocalTaskRequestFingerprint({
|
|
775
|
+
instruction,
|
|
776
|
+
backend: body.backend,
|
|
777
|
+
operation: guardrail.operation,
|
|
778
|
+
permissionMode: guardrail.permission_mode,
|
|
779
|
+
workspaceMode,
|
|
780
|
+
repository,
|
|
781
|
+
expectedOutputs: body.expected_outputs,
|
|
782
|
+
providerTokenBudget: body.provider_token_budget,
|
|
783
|
+
timeoutMs: body.timeout_ms,
|
|
784
|
+
attachmentManifest: preparedAttachments,
|
|
785
|
+
scenarioPack,
|
|
786
|
+
});
|
|
787
|
+
hashLocalTaskIdempotencyKey(idempotencyKey);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
let requestClaim;
|
|
793
|
+
while (true) {
|
|
794
|
+
requestClaim = localTaskRegistry.claimRequest(idempotencyKey, requestFingerprint);
|
|
795
|
+
if (requestClaim.status === "conflict") {
|
|
796
|
+
json(res, 409, {
|
|
797
|
+
error: "Idempotency-Key was already used for a different Local Task request.",
|
|
798
|
+
code: "LOCAL_TASK_IDEMPOTENCY_CONFLICT",
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (requestClaim.status === "existing") {
|
|
803
|
+
res.setHeader("Location", `/v1/local-tasks/${requestClaim.task.id}`);
|
|
804
|
+
json(res, 200, {
|
|
805
|
+
ok: true,
|
|
806
|
+
replayed: true,
|
|
807
|
+
task: localTaskSnapshot(requestClaim.task),
|
|
808
|
+
});
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (requestClaim.status === "pending") {
|
|
812
|
+
const pendingTask = await requestClaim.promise;
|
|
813
|
+
if (!pendingTask) continue;
|
|
814
|
+
res.setHeader("Location", `/v1/local-tasks/${pendingTask.id}`);
|
|
815
|
+
json(res, 200, {
|
|
816
|
+
ok: true,
|
|
817
|
+
replayed: true,
|
|
818
|
+
task: localTaskSnapshot(pendingTask),
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let requestClaimCompleted = false;
|
|
826
|
+
let releaseUpdateWork = null;
|
|
827
|
+
let runtimeOwnsUpdateWork = false;
|
|
828
|
+
try {
|
|
829
|
+
if (!acceptsNewWork(updateManager)) {
|
|
830
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
releaseUpdateWork = acquireUpdateWork(updateManager);
|
|
834
|
+
if (!releaseUpdateWork) {
|
|
835
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const inspection = normalizeBackendInspection(body.backend, availableCapabilities);
|
|
839
|
+
if (!inspection.compatible) {
|
|
840
|
+
const summary = backendPreflightSummary(body.backend, inspection);
|
|
841
|
+
json(res, 409, {
|
|
842
|
+
error: summary,
|
|
843
|
+
code: `desktop_executor_${inspection.status}`,
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
let workspace;
|
|
849
|
+
if (workspaceMode === "repository") {
|
|
850
|
+
if (!repository) {
|
|
851
|
+
json(res, 400, { error: "repository is required for this Local Task" });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const indexedRepository = await ensureRepositoryIndexed(repository);
|
|
855
|
+
if (!indexedRepository?.local_path) {
|
|
856
|
+
json(res, 409, {
|
|
857
|
+
error: `${repository} is not authorized on this SIMY CLI.`,
|
|
858
|
+
code: "repository_not_authorized",
|
|
859
|
+
});
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const id = createLocalTaskId();
|
|
863
|
+
const prepared = await prepareLocalTaskWorkspace({
|
|
864
|
+
taskId: id,
|
|
865
|
+
root: taskStorageRoot,
|
|
866
|
+
workspaceRoot: taskStorageRoot,
|
|
867
|
+
});
|
|
868
|
+
workspace = {
|
|
869
|
+
...prepared,
|
|
870
|
+
id,
|
|
871
|
+
workspace_path: indexedRepository.local_path,
|
|
872
|
+
};
|
|
873
|
+
} else {
|
|
874
|
+
const id = createLocalTaskId();
|
|
875
|
+
workspace = {
|
|
876
|
+
id,
|
|
877
|
+
...(await prepareLocalTaskWorkspace({
|
|
878
|
+
taskId: id,
|
|
879
|
+
root: taskStorageRoot,
|
|
880
|
+
workspaceRoot: dependencies.localTaskWorkspaceRoot,
|
|
881
|
+
})),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
const stagedAttachments = await stagePreparedRunAttachments({
|
|
885
|
+
runId: workspace.id,
|
|
886
|
+
prepared: preparedAttachments,
|
|
887
|
+
root: taskStorageRoot,
|
|
888
|
+
});
|
|
889
|
+
const task = createLocalTask({
|
|
890
|
+
id: workspace.id,
|
|
891
|
+
instruction,
|
|
892
|
+
backend: body.backend,
|
|
893
|
+
operation: guardrail.operation,
|
|
894
|
+
permissionMode: guardrail.permission_mode,
|
|
895
|
+
workspaceMode,
|
|
896
|
+
repository,
|
|
897
|
+
workspacePath: workspace.workspace_path,
|
|
898
|
+
taskRoot: workspace.task_root,
|
|
899
|
+
inputsRoot: workspace.inputs_root,
|
|
900
|
+
outputsRoot: workspace.outputs_root,
|
|
901
|
+
outputsRootExpected: workspace.outputs_root_expected,
|
|
902
|
+
attachments: stagedAttachments,
|
|
903
|
+
expectedOutputs: body.expected_outputs,
|
|
904
|
+
scenarioPack,
|
|
905
|
+
providerTokenBudget: body.provider_token_budget,
|
|
906
|
+
timeoutMs: body.timeout_ms,
|
|
907
|
+
idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
|
|
908
|
+
requestFingerprint,
|
|
909
|
+
});
|
|
910
|
+
await persistLocalTask(task);
|
|
911
|
+
localTaskRegistry.create(task);
|
|
912
|
+
requestClaim.complete(task);
|
|
913
|
+
requestClaimCompleted = true;
|
|
914
|
+
const runLocalTask = dependencies.runLocalTask || startLocalTask;
|
|
915
|
+
task.operation_promise = Promise.resolve(runLocalTask(task))
|
|
916
|
+
.catch(async (error) => {
|
|
917
|
+
task.status = "failed";
|
|
918
|
+
task.control_state = "complete";
|
|
919
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
920
|
+
task.reason_code ||= "local_task_runtime_error";
|
|
921
|
+
task.completed_at = new Date().toISOString();
|
|
922
|
+
task.updated_at = task.completed_at;
|
|
923
|
+
await persistLocalTask(task);
|
|
924
|
+
})
|
|
925
|
+
.finally(() => {
|
|
926
|
+
task.operation_promise = null;
|
|
927
|
+
releaseUpdateWork();
|
|
928
|
+
});
|
|
929
|
+
runtimeOwnsUpdateWork = true;
|
|
930
|
+
res.setHeader("Location", `/v1/local-tasks/${task.id}`);
|
|
931
|
+
json(res, 202, {
|
|
932
|
+
ok: true,
|
|
933
|
+
replayed: false,
|
|
934
|
+
task: localTaskSnapshot(task),
|
|
935
|
+
});
|
|
936
|
+
return;
|
|
937
|
+
} finally {
|
|
938
|
+
if (!requestClaimCompleted) requestClaim.release();
|
|
939
|
+
if (releaseUpdateWork && !runtimeOwnsUpdateWork) releaseUpdateWork();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
391
942
|
const directExecutionMatch = url.pathname.match(/^\/v1\/direct-execution\/([^/]+)$/);
|
|
392
943
|
if (req.method === "GET" && directExecutionMatch) {
|
|
393
944
|
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
@@ -486,11 +1037,18 @@ export async function startAgent({
|
|
|
486
1037
|
});
|
|
487
1038
|
directRegistry.create(task);
|
|
488
1039
|
const runDirectTask = dependencies.runDirectTask || startDirectTask;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
1040
|
+
const releaseUpdateWork = acquireUpdateWork(updateManager);
|
|
1041
|
+
if (!releaseUpdateWork) {
|
|
1042
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
void Promise.resolve(runDirectTask(task))
|
|
1046
|
+
.catch((error) => {
|
|
1047
|
+
task.status = "failed";
|
|
1048
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
1049
|
+
task.updated_at = new Date().toISOString();
|
|
1050
|
+
})
|
|
1051
|
+
.finally(releaseUpdateWork);
|
|
494
1052
|
json(res, 202, { ok: true, task: directTaskSnapshot(task) });
|
|
495
1053
|
return;
|
|
496
1054
|
}
|
|
@@ -878,6 +1436,10 @@ export async function startAgent({
|
|
|
878
1436
|
|
|
879
1437
|
json(res, 404, { error: "not found" });
|
|
880
1438
|
} catch (err) {
|
|
1439
|
+
if (err instanceof LocalTaskFileCapabilityError) {
|
|
1440
|
+
json(res, 400, localTaskFileErrorPayload(err));
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
881
1443
|
json(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
882
1444
|
}
|
|
883
1445
|
});
|
|
@@ -915,23 +1477,38 @@ export async function startAgent({
|
|
|
915
1477
|
startHeartbeatTimer();
|
|
916
1478
|
}
|
|
917
1479
|
|
|
1480
|
+
let shutdownPromise = null;
|
|
1481
|
+
const shutdown = () => {
|
|
1482
|
+
if (shutdownPromise) return shutdownPromise;
|
|
1483
|
+
shutdownPromise = (async () => {
|
|
1484
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
1485
|
+
await durableLocalTaskWorker?.close?.();
|
|
1486
|
+
updateManager?.stop?.();
|
|
1487
|
+
registry.close();
|
|
1488
|
+
localTaskRegistry.close();
|
|
1489
|
+
})();
|
|
1490
|
+
return shutdownPromise;
|
|
1491
|
+
};
|
|
918
1492
|
server.on("close", () => {
|
|
919
|
-
|
|
920
|
-
updateManager?.stop?.();
|
|
921
|
-
registry.close();
|
|
1493
|
+
void shutdown().catch((error) => reportDurableLocalTaskError(error, quiet));
|
|
922
1494
|
});
|
|
923
1495
|
return {
|
|
924
1496
|
server,
|
|
925
1497
|
port,
|
|
926
1498
|
registry,
|
|
927
1499
|
directRegistry,
|
|
1500
|
+
localTaskRegistry,
|
|
1501
|
+
durableLocalTaskWorker,
|
|
1502
|
+
shutdown,
|
|
928
1503
|
webOrigin: apiOrigin,
|
|
929
1504
|
get loginUrl() {
|
|
930
1505
|
return loginUrl?.toString() ?? null;
|
|
931
1506
|
},
|
|
932
1507
|
workspace,
|
|
933
1508
|
repositoryScanRoot,
|
|
934
|
-
capabilities
|
|
1509
|
+
get capabilities() {
|
|
1510
|
+
return refreshAvailableCapabilities();
|
|
1511
|
+
},
|
|
935
1512
|
updates: updateManager,
|
|
936
1513
|
controls: {
|
|
937
1514
|
repositoryInventory: () => [...repositoryInventory],
|
|
@@ -1132,6 +1709,13 @@ function reportHeartbeatError(error, quiet) {
|
|
|
1132
1709
|
);
|
|
1133
1710
|
}
|
|
1134
1711
|
|
|
1712
|
+
function reportDurableLocalTaskError(error, quiet) {
|
|
1713
|
+
if (quiet) return;
|
|
1714
|
+
console.error(
|
|
1715
|
+
`SIMY Local Task worker: ${error instanceof Error ? error.message : String(error)}`,
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1135
1719
|
function reportStartupConnectionError(error, quiet) {
|
|
1136
1720
|
if (quiet) return;
|
|
1137
1721
|
console.error(
|
|
@@ -1212,6 +1796,18 @@ async function capabilities() {
|
|
|
1212
1796
|
executor_version_preflight: true,
|
|
1213
1797
|
desktop_executor: true,
|
|
1214
1798
|
direct_execution: true,
|
|
1799
|
+
local_tasks: true,
|
|
1800
|
+
local_task_attachments: true,
|
|
1801
|
+
local_task_artifacts: true,
|
|
1802
|
+
local_task_streaming: true,
|
|
1803
|
+
local_task_idempotency: true,
|
|
1804
|
+
local_task_reconciliation: true,
|
|
1805
|
+
local_task_control_plane: true,
|
|
1806
|
+
pipeline_local_task_artifacts: true,
|
|
1807
|
+
durable_local_task_attachments: true,
|
|
1808
|
+
durable_local_task_scenario_packs: true,
|
|
1809
|
+
durable_local_task_steps: true,
|
|
1810
|
+
bounded_local_task_subtasks: true,
|
|
1215
1811
|
},
|
|
1216
1812
|
session_ttl_hours: 48,
|
|
1217
1813
|
};
|
|
@@ -1375,6 +1971,38 @@ function canonicalAgenticLoopPath(pathname) {
|
|
|
1375
1971
|
return pathname.replace(/^\/v1\/coding-loop(?=\/|$)/, "/v1/agentic-loop");
|
|
1376
1972
|
}
|
|
1377
1973
|
|
|
1974
|
+
function streamLocalTask(res, task) {
|
|
1975
|
+
if (!task) {
|
|
1976
|
+
json(res, 404, { error: "Local Task not found" });
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
res.writeHead(200, {
|
|
1980
|
+
"Content-Type": "text/event-stream",
|
|
1981
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1982
|
+
Connection: "keep-alive",
|
|
1983
|
+
});
|
|
1984
|
+
const send = (event, data) => {
|
|
1985
|
+
res.write(`event: ${event}\n`);
|
|
1986
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
1987
|
+
};
|
|
1988
|
+
send("snapshot", localTaskSnapshot(task));
|
|
1989
|
+
const listener = (event) => {
|
|
1990
|
+
send(event.type || "event", event);
|
|
1991
|
+
if (["succeeded", "failed", "stopped"].includes(event.state)) {
|
|
1992
|
+
send("snapshot", localTaskSnapshot(task));
|
|
1993
|
+
}
|
|
1994
|
+
};
|
|
1995
|
+
task.emitter.on("event", listener);
|
|
1996
|
+
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15_000);
|
|
1997
|
+
heartbeat.unref?.();
|
|
1998
|
+
const close = () => {
|
|
1999
|
+
clearInterval(heartbeat);
|
|
2000
|
+
task.emitter.off("event", listener);
|
|
2001
|
+
};
|
|
2002
|
+
res.on("close", close);
|
|
2003
|
+
res.on("error", close);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
1378
2006
|
function streamRun(res, run) {
|
|
1379
2007
|
if (!run) {
|
|
1380
2008
|
json(res, 404, { error: "run not found" });
|
|
@@ -1408,7 +2036,11 @@ function applyCors(req, res, webOrigin) {
|
|
|
1408
2036
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
1409
2037
|
}
|
|
1410
2038
|
}
|
|
1411
|
-
res.setHeader(
|
|
2039
|
+
res.setHeader(
|
|
2040
|
+
"Access-Control-Allow-Headers",
|
|
2041
|
+
"Content-Type, Authorization, Idempotency-Key",
|
|
2042
|
+
);
|
|
2043
|
+
res.setHeader("Access-Control-Expose-Headers", "Location");
|
|
1412
2044
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1413
2045
|
}
|
|
1414
2046
|
|
|
@@ -1428,20 +2060,91 @@ async function readCodingLoopStart(req) {
|
|
|
1428
2060
|
if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
|
|
1429
2061
|
return { body: await readJson(req), attachments: [] };
|
|
1430
2062
|
}
|
|
2063
|
+
const form = await readMultipartForm(req);
|
|
2064
|
+
const payload = form.get("payload");
|
|
2065
|
+
if (typeof payload !== "string") throw new Error("multipart payload field is required");
|
|
2066
|
+
const attachments = form
|
|
2067
|
+
.getAll("attachments")
|
|
2068
|
+
.filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
|
|
2069
|
+
return { body: JSON.parse(payload), attachments };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
async function readAttachmentStage(req) {
|
|
2073
|
+
const contentType = String(req.headers["content-type"] || "");
|
|
2074
|
+
if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
|
|
2075
|
+
throw new LocalTaskFileCapabilityError(
|
|
2076
|
+
"Choose files to attach before continuing.",
|
|
2077
|
+
{
|
|
2078
|
+
code: "local_task_attachment_multipart_required",
|
|
2079
|
+
recoveryAction: {
|
|
2080
|
+
id: "reattach_file",
|
|
2081
|
+
label: "Attach the files again",
|
|
2082
|
+
description: "Choose the original files again.",
|
|
2083
|
+
},
|
|
2084
|
+
},
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
const form = await readMultipartForm(req);
|
|
2088
|
+
const requestedVersion = form.get("capability_version");
|
|
2089
|
+
if (
|
|
2090
|
+
requestedVersion !== null &&
|
|
2091
|
+
requestedVersion !== LOCAL_TASK_FILE_CAPABILITY_VERSION
|
|
2092
|
+
) {
|
|
2093
|
+
throw new LocalTaskFileCapabilityError(
|
|
2094
|
+
"Update SIMY CLI before attaching files.",
|
|
2095
|
+
{
|
|
2096
|
+
code: "local_task_attachment_version_unsupported",
|
|
2097
|
+
recoveryAction: {
|
|
2098
|
+
id: "update_cli",
|
|
2099
|
+
label: "Update SIMY CLI",
|
|
2100
|
+
description: "Update and restart SIMY CLI, then attach the files again.",
|
|
2101
|
+
},
|
|
2102
|
+
},
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
// Caller-provided names, MIME types, sizes, and hashes are intentionally not
|
|
2106
|
+
// trusted. The CLI derives and verifies the complete manifest from File bytes.
|
|
2107
|
+
return form
|
|
2108
|
+
.getAll("attachments")
|
|
2109
|
+
.filter(
|
|
2110
|
+
(value) =>
|
|
2111
|
+
value &&
|
|
2112
|
+
typeof value === "object" &&
|
|
2113
|
+
typeof value.arrayBuffer === "function",
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
export async function readMultipartForm(
|
|
2118
|
+
req,
|
|
2119
|
+
{ maxBytes = 52 * 1024 * 1024 } = {},
|
|
2120
|
+
) {
|
|
2121
|
+
const contentType = String(req.headers["content-type"] || "");
|
|
2122
|
+
const declaredSize = Number(req.headers["content-length"]);
|
|
2123
|
+
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
|
2124
|
+
throw multipartTooLargeError();
|
|
2125
|
+
}
|
|
1431
2126
|
const chunks = [];
|
|
1432
2127
|
let size = 0;
|
|
1433
2128
|
for await (const chunk of req) {
|
|
1434
2129
|
size += chunk.length;
|
|
1435
|
-
if (size >
|
|
2130
|
+
if (size > maxBytes) throw multipartTooLargeError();
|
|
1436
2131
|
chunks.push(Buffer.from(chunk));
|
|
1437
2132
|
}
|
|
1438
|
-
|
|
2133
|
+
return new Response(Buffer.concat(chunks), {
|
|
1439
2134
|
headers: { "Content-Type": contentType },
|
|
1440
2135
|
}).formData();
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function multipartTooLargeError() {
|
|
2139
|
+
return new LocalTaskFileCapabilityError(
|
|
2140
|
+
"The attached files are larger than the 50 MiB request limit.",
|
|
2141
|
+
{
|
|
2142
|
+
code: "local_task_attachment_request_too_large",
|
|
2143
|
+
recoveryAction: {
|
|
2144
|
+
id: "reduce_file_size",
|
|
2145
|
+
label: "Use smaller files",
|
|
2146
|
+
description: "Compress, shorten, or split the files before attaching them again.",
|
|
2147
|
+
},
|
|
2148
|
+
},
|
|
2149
|
+
);
|
|
1447
2150
|
}
|