@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/src/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { dirname, resolve } from "node:path";
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 availableCapabilities = withCliContract(
121
- await readCapabilities(dependencies),
122
- installMode,
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 = heartbeatDevice({
169
- apiOrigin,
170
- apiBaseUrl: session.api_base_url,
171
- token: session.token,
172
- port,
173
- capabilities: availableCapabilities,
174
- repoInventory: repositoryInventory,
175
- })
176
- .then(() => true)
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
- if (indexed || !isRepositoryScanAuthorized()) return indexed;
323
+ const verified = indexed
324
+ ? await verifyIdentity(indexed, repository)
325
+ : null;
326
+ if (verified || !isRepositoryScanAuthorized()) return verified;
239
327
  await refreshAuthorizedRepositoryInventory();
240
- return findRepository(repositoryInventory, repository);
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
- agenticLoopPath !== "/v1/agentic-loop/preflight"
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, availableCapabilities);
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
- void Promise.resolve(runDirectTask(task)).catch((error) => {
490
- task.status = "failed";
491
- task.error = error instanceof Error ? error.message : String(error);
492
- task.updated_at = new Date().toISOString();
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
- if (heartbeatTimer) clearInterval(heartbeatTimer);
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: availableCapabilities,
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("Access-Control-Allow-Headers", "Content-Type, Authorization");
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 > 52 * 1024 * 1024) throw new Error("attachment request exceeds the 52 MB limit");
2130
+ if (size > maxBytes) throw multipartTooLargeError();
1436
2131
  chunks.push(Buffer.from(chunk));
1437
2132
  }
1438
- const form = await new Response(Buffer.concat(chunks), {
2133
+ return new Response(Buffer.concat(chunks), {
1439
2134
  headers: { "Content-Type": contentType },
1440
2135
  }).formData();
1441
- const payload = form.get("payload");
1442
- if (typeof payload !== "string") throw new Error("multipart payload field is required");
1443
- const attachments = form
1444
- .getAll("attachments")
1445
- .filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
1446
- return { body: JSON.parse(payload), attachments };
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
  }