@awak-app/simy-cli 0.2.2 → 0.3.3
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 +90 -1
- package/package.json +12 -4
- package/src/agent.js +723 -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 +8 -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 +1094 -0
- package/src/orchestrator/audit.js +90 -2
- package/src/orchestrator/completion-contract.js +159 -0
- package/src/orchestrator/contract.js +23 -12
- package/src/orchestrator/independent-audit.js +88 -3
- package/src/orchestrator/instruction.js +26 -4
- package/src/orchestrator/loop.js +5 -1
- package/src/orchestrator/result.js +10 -0
- package/src/repository-inventory.js +37 -1
- package/src/runner.js +1 -0
- package/src/shutdown.js +63 -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") {
|
|
@@ -388,6 +572,356 @@ export async function startAgent({
|
|
|
388
572
|
void synchronizeAuthorizedSession();
|
|
389
573
|
return;
|
|
390
574
|
}
|
|
575
|
+
const localTaskArtifactMatch = url.pathname.match(
|
|
576
|
+
/^\/v1\/local-tasks\/([^/]+)\/artifacts\/([^/]+)$/,
|
|
577
|
+
);
|
|
578
|
+
if (req.method === "GET" && localTaskArtifactMatch) {
|
|
579
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
580
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const task = localTaskRegistry.get(decodeURIComponent(localTaskArtifactMatch[1]));
|
|
584
|
+
const artifact = await localTaskArtifact(
|
|
585
|
+
task,
|
|
586
|
+
decodeURIComponent(localTaskArtifactMatch[2]),
|
|
587
|
+
);
|
|
588
|
+
if (!task || !artifact) {
|
|
589
|
+
json(res, 404, { error: "Local Task artifact not found" });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
res.writeHead(200, {
|
|
593
|
+
"Content-Type": artifact.mime_type,
|
|
594
|
+
"Content-Length": String(artifact.size_bytes),
|
|
595
|
+
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(artifact.name)}`,
|
|
596
|
+
"X-Content-Type-Options": "nosniff",
|
|
597
|
+
});
|
|
598
|
+
createReadStream(artifact.local_path)
|
|
599
|
+
.on("error", () => {
|
|
600
|
+
if (!res.headersSent) json(res, 500, { error: "Local Task artifact is unavailable" });
|
|
601
|
+
else res.destroy();
|
|
602
|
+
})
|
|
603
|
+
.pipe(res);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const localTaskStreamMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/stream$/);
|
|
607
|
+
if (req.method === "GET" && localTaskStreamMatch) {
|
|
608
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
609
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
streamLocalTask(res, localTaskRegistry.get(decodeURIComponent(localTaskStreamMatch[1])));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const localTaskControlMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/control$/);
|
|
616
|
+
if (req.method === "POST" && localTaskControlMatch) {
|
|
617
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
618
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const task = localTaskRegistry.get(decodeURIComponent(localTaskControlMatch[1]));
|
|
622
|
+
if (!task) {
|
|
623
|
+
json(res, 404, { error: "Local Task not found" });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const body = await readJson(req);
|
|
627
|
+
if (body.action !== "stop") {
|
|
628
|
+
json(res, 400, { error: "Local Task control action must be stop" });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
await stopLocalTask(task);
|
|
632
|
+
json(res, 200, { ok: true, task: localTaskSnapshot(task) });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const localTaskMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)$/);
|
|
636
|
+
if (req.method === "GET" && url.pathname === "/v1/local-tasks/reconcile") {
|
|
637
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
638
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
let task;
|
|
642
|
+
try {
|
|
643
|
+
task = localTaskRegistry.getByIdempotencyKey(req.headers["idempotency-key"]);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!task) {
|
|
649
|
+
res.setHeader("Cache-Control", "no-store");
|
|
650
|
+
json(res, 404, { error: "Local Task request not found" });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
res.setHeader("Cache-Control", "no-store");
|
|
654
|
+
res.setHeader("Location", `/v1/local-tasks/${task.id}`);
|
|
655
|
+
json(res, 200, { ok: true, replayed: true, task: localTaskSnapshot(task) });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (req.method === "GET" && localTaskMatch) {
|
|
659
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
660
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const task = localTaskRegistry.get(decodeURIComponent(localTaskMatch[1]));
|
|
664
|
+
if (!task) {
|
|
665
|
+
json(res, 404, { error: "Local Task not found" });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
json(res, 200, { task: localTaskSnapshot(task) });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (req.method === "POST" && url.pathname === "/v1/local-tasks/start") {
|
|
672
|
+
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
673
|
+
json(res, 401, { error: "simy session expired; run simy again" });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const { body, attachments } = await readCodingLoopStart(req);
|
|
677
|
+
const instruction = String(body.instruction || "").trim();
|
|
678
|
+
if (!instruction) {
|
|
679
|
+
json(res, 400, { error: "instruction is required" });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (body.backend !== "codex" && body.backend !== "claude") {
|
|
683
|
+
json(res, 400, { error: "backend must be codex or claude" });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const idempotencyKey = String(
|
|
687
|
+
req.headers["idempotency-key"] || body.client_request_id || "",
|
|
688
|
+
).trim();
|
|
689
|
+
const guardrail = classifyExecutionRequirement(instruction);
|
|
690
|
+
if (
|
|
691
|
+
guardrail.decision !== "direct_executor" ||
|
|
692
|
+
body.guardrail?.policy_version !== EXECUTION_GUARDRAIL_POLICY_VERSION ||
|
|
693
|
+
body.guardrail?.decision !== "direct_executor"
|
|
694
|
+
) {
|
|
695
|
+
json(res, 409, {
|
|
696
|
+
error: "This request is not authorized as a Local Task.",
|
|
697
|
+
code: "LOCAL_TASK_GUARD_REQUIRED",
|
|
698
|
+
guardrail,
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (
|
|
703
|
+
body.operation !== guardrail.operation ||
|
|
704
|
+
body.permission_mode !== guardrail.permission_mode ||
|
|
705
|
+
body.max_provider_invocations !== 1
|
|
706
|
+
) {
|
|
707
|
+
json(res, 409, {
|
|
708
|
+
error: "Local Task permissions do not match the task-routing decision.",
|
|
709
|
+
code: "LOCAL_TASK_GUARD_MISMATCH",
|
|
710
|
+
guardrail,
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const workspaceMode = String(
|
|
715
|
+
body.workspace_mode || guardrail.workspace_kind || "task_sandbox",
|
|
716
|
+
);
|
|
717
|
+
const repository = String(body.repository || "").trim() || null;
|
|
718
|
+
if (workspaceMode !== "repository" && workspaceMode !== "task_sandbox") {
|
|
719
|
+
json(res, 400, {
|
|
720
|
+
error: "workspace_mode must be task_sandbox or repository",
|
|
721
|
+
code: "unsupported_local_task_workspace",
|
|
722
|
+
});
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
let preparedAttachments;
|
|
726
|
+
try {
|
|
727
|
+
preparedAttachments = await prepareRunAttachments({
|
|
728
|
+
attachments,
|
|
729
|
+
manifest: attachments.length > 0 ? body.attachment_manifest ?? [] : undefined,
|
|
730
|
+
});
|
|
731
|
+
} catch (error) {
|
|
732
|
+
json(res, 400, {
|
|
733
|
+
error: error instanceof Error ? error.message : "attachment rejected",
|
|
734
|
+
});
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
let scenarioPack;
|
|
738
|
+
try {
|
|
739
|
+
scenarioPack = normalizeLocalTaskScenarioPack(body.scenario_pack);
|
|
740
|
+
validateLocalTaskScenarioInputs(scenarioPack, {
|
|
741
|
+
instruction,
|
|
742
|
+
attachments: preparedAttachments,
|
|
743
|
+
});
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (error instanceof LocalTaskScenarioPackError) {
|
|
746
|
+
json(res, 400, {
|
|
747
|
+
error: error.message,
|
|
748
|
+
code: error.code,
|
|
749
|
+
recovery: error.recovery,
|
|
750
|
+
});
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
let requestFingerprint;
|
|
756
|
+
try {
|
|
757
|
+
requestFingerprint = createLocalTaskRequestFingerprint({
|
|
758
|
+
instruction,
|
|
759
|
+
backend: body.backend,
|
|
760
|
+
operation: guardrail.operation,
|
|
761
|
+
permissionMode: guardrail.permission_mode,
|
|
762
|
+
workspaceMode,
|
|
763
|
+
repository,
|
|
764
|
+
expectedOutputs: body.expected_outputs,
|
|
765
|
+
providerTokenBudget: body.provider_token_budget,
|
|
766
|
+
timeoutMs: body.timeout_ms,
|
|
767
|
+
attachmentManifest: preparedAttachments,
|
|
768
|
+
scenarioPack,
|
|
769
|
+
});
|
|
770
|
+
hashLocalTaskIdempotencyKey(idempotencyKey);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
json(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
let requestClaim;
|
|
776
|
+
while (true) {
|
|
777
|
+
requestClaim = localTaskRegistry.claimRequest(idempotencyKey, requestFingerprint);
|
|
778
|
+
if (requestClaim.status === "conflict") {
|
|
779
|
+
json(res, 409, {
|
|
780
|
+
error: "Idempotency-Key was already used for a different Local Task request.",
|
|
781
|
+
code: "LOCAL_TASK_IDEMPOTENCY_CONFLICT",
|
|
782
|
+
});
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (requestClaim.status === "existing") {
|
|
786
|
+
res.setHeader("Location", `/v1/local-tasks/${requestClaim.task.id}`);
|
|
787
|
+
json(res, 200, {
|
|
788
|
+
ok: true,
|
|
789
|
+
replayed: true,
|
|
790
|
+
task: localTaskSnapshot(requestClaim.task),
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (requestClaim.status === "pending") {
|
|
795
|
+
const pendingTask = await requestClaim.promise;
|
|
796
|
+
if (!pendingTask) continue;
|
|
797
|
+
res.setHeader("Location", `/v1/local-tasks/${pendingTask.id}`);
|
|
798
|
+
json(res, 200, {
|
|
799
|
+
ok: true,
|
|
800
|
+
replayed: true,
|
|
801
|
+
task: localTaskSnapshot(pendingTask),
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
let requestClaimCompleted = false;
|
|
809
|
+
let releaseUpdateWork = null;
|
|
810
|
+
let runtimeOwnsUpdateWork = false;
|
|
811
|
+
try {
|
|
812
|
+
if (!acceptsNewWork(updateManager)) {
|
|
813
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
releaseUpdateWork = acquireUpdateWork(updateManager);
|
|
817
|
+
if (!releaseUpdateWork) {
|
|
818
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const inspection = normalizeBackendInspection(body.backend, availableCapabilities);
|
|
822
|
+
if (!inspection.compatible) {
|
|
823
|
+
const summary = backendPreflightSummary(body.backend, inspection);
|
|
824
|
+
json(res, 409, {
|
|
825
|
+
error: summary,
|
|
826
|
+
code: `desktop_executor_${inspection.status}`,
|
|
827
|
+
});
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
let workspace;
|
|
832
|
+
if (workspaceMode === "repository") {
|
|
833
|
+
if (!repository) {
|
|
834
|
+
json(res, 400, { error: "repository is required for this Local Task" });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const indexedRepository = await ensureRepositoryIndexed(repository);
|
|
838
|
+
if (!indexedRepository?.local_path) {
|
|
839
|
+
json(res, 409, {
|
|
840
|
+
error: `${repository} is not authorized on this SIMY CLI.`,
|
|
841
|
+
code: "repository_not_authorized",
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const id = createLocalTaskId();
|
|
846
|
+
const prepared = await prepareLocalTaskWorkspace({
|
|
847
|
+
taskId: id,
|
|
848
|
+
root: taskStorageRoot,
|
|
849
|
+
workspaceRoot: taskStorageRoot,
|
|
850
|
+
});
|
|
851
|
+
workspace = {
|
|
852
|
+
...prepared,
|
|
853
|
+
id,
|
|
854
|
+
workspace_path: indexedRepository.local_path,
|
|
855
|
+
};
|
|
856
|
+
} else {
|
|
857
|
+
const id = createLocalTaskId();
|
|
858
|
+
workspace = {
|
|
859
|
+
id,
|
|
860
|
+
...(await prepareLocalTaskWorkspace({
|
|
861
|
+
taskId: id,
|
|
862
|
+
root: taskStorageRoot,
|
|
863
|
+
workspaceRoot: dependencies.localTaskWorkspaceRoot,
|
|
864
|
+
})),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const stagedAttachments = await stagePreparedRunAttachments({
|
|
868
|
+
runId: workspace.id,
|
|
869
|
+
prepared: preparedAttachments,
|
|
870
|
+
root: taskStorageRoot,
|
|
871
|
+
});
|
|
872
|
+
const task = createLocalTask({
|
|
873
|
+
id: workspace.id,
|
|
874
|
+
instruction,
|
|
875
|
+
backend: body.backend,
|
|
876
|
+
operation: guardrail.operation,
|
|
877
|
+
permissionMode: guardrail.permission_mode,
|
|
878
|
+
workspaceMode,
|
|
879
|
+
repository,
|
|
880
|
+
workspacePath: workspace.workspace_path,
|
|
881
|
+
taskRoot: workspace.task_root,
|
|
882
|
+
inputsRoot: workspace.inputs_root,
|
|
883
|
+
outputsRoot: workspace.outputs_root,
|
|
884
|
+
outputsRootExpected: workspace.outputs_root_expected,
|
|
885
|
+
attachments: stagedAttachments,
|
|
886
|
+
expectedOutputs: body.expected_outputs,
|
|
887
|
+
scenarioPack,
|
|
888
|
+
providerTokenBudget: body.provider_token_budget,
|
|
889
|
+
timeoutMs: body.timeout_ms,
|
|
890
|
+
idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
|
|
891
|
+
requestFingerprint,
|
|
892
|
+
});
|
|
893
|
+
await persistLocalTask(task);
|
|
894
|
+
localTaskRegistry.create(task);
|
|
895
|
+
requestClaim.complete(task);
|
|
896
|
+
requestClaimCompleted = true;
|
|
897
|
+
const runLocalTask = dependencies.runLocalTask || startLocalTask;
|
|
898
|
+
task.operation_promise = Promise.resolve(runLocalTask(task))
|
|
899
|
+
.catch(async (error) => {
|
|
900
|
+
task.status = "failed";
|
|
901
|
+
task.control_state = "complete";
|
|
902
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
903
|
+
task.reason_code ||= "local_task_runtime_error";
|
|
904
|
+
task.completed_at = new Date().toISOString();
|
|
905
|
+
task.updated_at = task.completed_at;
|
|
906
|
+
await persistLocalTask(task);
|
|
907
|
+
})
|
|
908
|
+
.finally(() => {
|
|
909
|
+
task.operation_promise = null;
|
|
910
|
+
releaseUpdateWork();
|
|
911
|
+
});
|
|
912
|
+
runtimeOwnsUpdateWork = true;
|
|
913
|
+
res.setHeader("Location", `/v1/local-tasks/${task.id}`);
|
|
914
|
+
json(res, 202, {
|
|
915
|
+
ok: true,
|
|
916
|
+
replayed: false,
|
|
917
|
+
task: localTaskSnapshot(task),
|
|
918
|
+
});
|
|
919
|
+
return;
|
|
920
|
+
} finally {
|
|
921
|
+
if (!requestClaimCompleted) requestClaim.release();
|
|
922
|
+
if (releaseUpdateWork && !runtimeOwnsUpdateWork) releaseUpdateWork();
|
|
923
|
+
}
|
|
924
|
+
}
|
|
391
925
|
const directExecutionMatch = url.pathname.match(/^\/v1\/direct-execution\/([^/]+)$/);
|
|
392
926
|
if (req.method === "GET" && directExecutionMatch) {
|
|
393
927
|
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
@@ -486,11 +1020,18 @@ export async function startAgent({
|
|
|
486
1020
|
});
|
|
487
1021
|
directRegistry.create(task);
|
|
488
1022
|
const runDirectTask = dependencies.runDirectTask || startDirectTask;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
1023
|
+
const releaseUpdateWork = acquireUpdateWork(updateManager);
|
|
1024
|
+
if (!releaseUpdateWork) {
|
|
1025
|
+
json(res, 503, updateInProgressResponse(updateManager));
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
void Promise.resolve(runDirectTask(task))
|
|
1029
|
+
.catch((error) => {
|
|
1030
|
+
task.status = "failed";
|
|
1031
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
1032
|
+
task.updated_at = new Date().toISOString();
|
|
1033
|
+
})
|
|
1034
|
+
.finally(releaseUpdateWork);
|
|
494
1035
|
json(res, 202, { ok: true, task: directTaskSnapshot(task) });
|
|
495
1036
|
return;
|
|
496
1037
|
}
|
|
@@ -878,6 +1419,10 @@ export async function startAgent({
|
|
|
878
1419
|
|
|
879
1420
|
json(res, 404, { error: "not found" });
|
|
880
1421
|
} catch (err) {
|
|
1422
|
+
if (err instanceof LocalTaskFileCapabilityError) {
|
|
1423
|
+
json(res, 400, localTaskFileErrorPayload(err));
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
881
1426
|
json(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
882
1427
|
}
|
|
883
1428
|
});
|
|
@@ -915,23 +1460,38 @@ export async function startAgent({
|
|
|
915
1460
|
startHeartbeatTimer();
|
|
916
1461
|
}
|
|
917
1462
|
|
|
1463
|
+
let shutdownPromise = null;
|
|
1464
|
+
const shutdown = () => {
|
|
1465
|
+
if (shutdownPromise) return shutdownPromise;
|
|
1466
|
+
shutdownPromise = (async () => {
|
|
1467
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
1468
|
+
await durableLocalTaskWorker?.close?.();
|
|
1469
|
+
updateManager?.stop?.();
|
|
1470
|
+
registry.close();
|
|
1471
|
+
localTaskRegistry.close();
|
|
1472
|
+
})();
|
|
1473
|
+
return shutdownPromise;
|
|
1474
|
+
};
|
|
918
1475
|
server.on("close", () => {
|
|
919
|
-
|
|
920
|
-
updateManager?.stop?.();
|
|
921
|
-
registry.close();
|
|
1476
|
+
void shutdown().catch((error) => reportDurableLocalTaskError(error, quiet));
|
|
922
1477
|
});
|
|
923
1478
|
return {
|
|
924
1479
|
server,
|
|
925
1480
|
port,
|
|
926
1481
|
registry,
|
|
927
1482
|
directRegistry,
|
|
1483
|
+
localTaskRegistry,
|
|
1484
|
+
durableLocalTaskWorker,
|
|
1485
|
+
shutdown,
|
|
928
1486
|
webOrigin: apiOrigin,
|
|
929
1487
|
get loginUrl() {
|
|
930
1488
|
return loginUrl?.toString() ?? null;
|
|
931
1489
|
},
|
|
932
1490
|
workspace,
|
|
933
1491
|
repositoryScanRoot,
|
|
934
|
-
capabilities
|
|
1492
|
+
get capabilities() {
|
|
1493
|
+
return refreshAvailableCapabilities();
|
|
1494
|
+
},
|
|
935
1495
|
updates: updateManager,
|
|
936
1496
|
controls: {
|
|
937
1497
|
repositoryInventory: () => [...repositoryInventory],
|
|
@@ -1132,6 +1692,13 @@ function reportHeartbeatError(error, quiet) {
|
|
|
1132
1692
|
);
|
|
1133
1693
|
}
|
|
1134
1694
|
|
|
1695
|
+
function reportDurableLocalTaskError(error, quiet) {
|
|
1696
|
+
if (quiet) return;
|
|
1697
|
+
console.error(
|
|
1698
|
+
`SIMY Local Task worker: ${error instanceof Error ? error.message : String(error)}`,
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1135
1702
|
function reportStartupConnectionError(error, quiet) {
|
|
1136
1703
|
if (quiet) return;
|
|
1137
1704
|
console.error(
|
|
@@ -1212,6 +1779,18 @@ async function capabilities() {
|
|
|
1212
1779
|
executor_version_preflight: true,
|
|
1213
1780
|
desktop_executor: true,
|
|
1214
1781
|
direct_execution: true,
|
|
1782
|
+
local_tasks: true,
|
|
1783
|
+
local_task_attachments: true,
|
|
1784
|
+
local_task_artifacts: true,
|
|
1785
|
+
local_task_streaming: true,
|
|
1786
|
+
local_task_idempotency: true,
|
|
1787
|
+
local_task_reconciliation: true,
|
|
1788
|
+
local_task_control_plane: true,
|
|
1789
|
+
pipeline_local_task_artifacts: true,
|
|
1790
|
+
durable_local_task_attachments: true,
|
|
1791
|
+
durable_local_task_scenario_packs: true,
|
|
1792
|
+
durable_local_task_steps: true,
|
|
1793
|
+
bounded_local_task_subtasks: true,
|
|
1215
1794
|
},
|
|
1216
1795
|
session_ttl_hours: 48,
|
|
1217
1796
|
};
|
|
@@ -1375,6 +1954,38 @@ function canonicalAgenticLoopPath(pathname) {
|
|
|
1375
1954
|
return pathname.replace(/^\/v1\/coding-loop(?=\/|$)/, "/v1/agentic-loop");
|
|
1376
1955
|
}
|
|
1377
1956
|
|
|
1957
|
+
function streamLocalTask(res, task) {
|
|
1958
|
+
if (!task) {
|
|
1959
|
+
json(res, 404, { error: "Local Task not found" });
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
res.writeHead(200, {
|
|
1963
|
+
"Content-Type": "text/event-stream",
|
|
1964
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1965
|
+
Connection: "keep-alive",
|
|
1966
|
+
});
|
|
1967
|
+
const send = (event, data) => {
|
|
1968
|
+
res.write(`event: ${event}\n`);
|
|
1969
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
1970
|
+
};
|
|
1971
|
+
send("snapshot", localTaskSnapshot(task));
|
|
1972
|
+
const listener = (event) => {
|
|
1973
|
+
send(event.type || "event", event);
|
|
1974
|
+
if (["succeeded", "failed", "stopped"].includes(event.state)) {
|
|
1975
|
+
send("snapshot", localTaskSnapshot(task));
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
task.emitter.on("event", listener);
|
|
1979
|
+
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15_000);
|
|
1980
|
+
heartbeat.unref?.();
|
|
1981
|
+
const close = () => {
|
|
1982
|
+
clearInterval(heartbeat);
|
|
1983
|
+
task.emitter.off("event", listener);
|
|
1984
|
+
};
|
|
1985
|
+
res.on("close", close);
|
|
1986
|
+
res.on("error", close);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1378
1989
|
function streamRun(res, run) {
|
|
1379
1990
|
if (!run) {
|
|
1380
1991
|
json(res, 404, { error: "run not found" });
|
|
@@ -1408,7 +2019,11 @@ function applyCors(req, res, webOrigin) {
|
|
|
1408
2019
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
1409
2020
|
}
|
|
1410
2021
|
}
|
|
1411
|
-
res.setHeader(
|
|
2022
|
+
res.setHeader(
|
|
2023
|
+
"Access-Control-Allow-Headers",
|
|
2024
|
+
"Content-Type, Authorization, Idempotency-Key",
|
|
2025
|
+
);
|
|
2026
|
+
res.setHeader("Access-Control-Expose-Headers", "Location");
|
|
1412
2027
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1413
2028
|
}
|
|
1414
2029
|
|
|
@@ -1428,20 +2043,91 @@ async function readCodingLoopStart(req) {
|
|
|
1428
2043
|
if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
|
|
1429
2044
|
return { body: await readJson(req), attachments: [] };
|
|
1430
2045
|
}
|
|
2046
|
+
const form = await readMultipartForm(req);
|
|
2047
|
+
const payload = form.get("payload");
|
|
2048
|
+
if (typeof payload !== "string") throw new Error("multipart payload field is required");
|
|
2049
|
+
const attachments = form
|
|
2050
|
+
.getAll("attachments")
|
|
2051
|
+
.filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
|
|
2052
|
+
return { body: JSON.parse(payload), attachments };
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
async function readAttachmentStage(req) {
|
|
2056
|
+
const contentType = String(req.headers["content-type"] || "");
|
|
2057
|
+
if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
|
|
2058
|
+
throw new LocalTaskFileCapabilityError(
|
|
2059
|
+
"Choose files to attach before continuing.",
|
|
2060
|
+
{
|
|
2061
|
+
code: "local_task_attachment_multipart_required",
|
|
2062
|
+
recoveryAction: {
|
|
2063
|
+
id: "reattach_file",
|
|
2064
|
+
label: "Attach the files again",
|
|
2065
|
+
description: "Choose the original files again.",
|
|
2066
|
+
},
|
|
2067
|
+
},
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
const form = await readMultipartForm(req);
|
|
2071
|
+
const requestedVersion = form.get("capability_version");
|
|
2072
|
+
if (
|
|
2073
|
+
requestedVersion !== null &&
|
|
2074
|
+
requestedVersion !== LOCAL_TASK_FILE_CAPABILITY_VERSION
|
|
2075
|
+
) {
|
|
2076
|
+
throw new LocalTaskFileCapabilityError(
|
|
2077
|
+
"Update SIMY CLI before attaching files.",
|
|
2078
|
+
{
|
|
2079
|
+
code: "local_task_attachment_version_unsupported",
|
|
2080
|
+
recoveryAction: {
|
|
2081
|
+
id: "update_cli",
|
|
2082
|
+
label: "Update SIMY CLI",
|
|
2083
|
+
description: "Update and restart SIMY CLI, then attach the files again.",
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
// Caller-provided names, MIME types, sizes, and hashes are intentionally not
|
|
2089
|
+
// trusted. The CLI derives and verifies the complete manifest from File bytes.
|
|
2090
|
+
return form
|
|
2091
|
+
.getAll("attachments")
|
|
2092
|
+
.filter(
|
|
2093
|
+
(value) =>
|
|
2094
|
+
value &&
|
|
2095
|
+
typeof value === "object" &&
|
|
2096
|
+
typeof value.arrayBuffer === "function",
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
export async function readMultipartForm(
|
|
2101
|
+
req,
|
|
2102
|
+
{ maxBytes = 52 * 1024 * 1024 } = {},
|
|
2103
|
+
) {
|
|
2104
|
+
const contentType = String(req.headers["content-type"] || "");
|
|
2105
|
+
const declaredSize = Number(req.headers["content-length"]);
|
|
2106
|
+
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
|
2107
|
+
throw multipartTooLargeError();
|
|
2108
|
+
}
|
|
1431
2109
|
const chunks = [];
|
|
1432
2110
|
let size = 0;
|
|
1433
2111
|
for await (const chunk of req) {
|
|
1434
2112
|
size += chunk.length;
|
|
1435
|
-
if (size >
|
|
2113
|
+
if (size > maxBytes) throw multipartTooLargeError();
|
|
1436
2114
|
chunks.push(Buffer.from(chunk));
|
|
1437
2115
|
}
|
|
1438
|
-
|
|
2116
|
+
return new Response(Buffer.concat(chunks), {
|
|
1439
2117
|
headers: { "Content-Type": contentType },
|
|
1440
2118
|
}).formData();
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function multipartTooLargeError() {
|
|
2122
|
+
return new LocalTaskFileCapabilityError(
|
|
2123
|
+
"The attached files are larger than the 50 MiB request limit.",
|
|
2124
|
+
{
|
|
2125
|
+
code: "local_task_attachment_request_too_large",
|
|
2126
|
+
recoveryAction: {
|
|
2127
|
+
id: "reduce_file_size",
|
|
2128
|
+
label: "Use smaller files",
|
|
2129
|
+
description: "Compress, shorten, or split the files before attaching them again.",
|
|
2130
|
+
},
|
|
2131
|
+
},
|
|
2132
|
+
);
|
|
1447
2133
|
}
|