@desplega.ai/agent-swarm 1.131.0 → 1.133.1
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 +2 -2
- package/dist/{actions-w53mbns9.js → actions-cskpayd7.js} +11 -9
- package/dist/{anthropic-messages-rjmd2e7n.js → anthropic-messages-tpxtbx7b.js} +28 -12
- package/dist/{app-m9gwqsqv.js → app-ang77nbb.js} +8 -6
- package/dist/{assistant-bp65m6f3.js → assistant-b4exebdw.js} +16 -13
- package/dist/{azure-openai-responses-c0yjmbfd.js → azure-openai-responses-m3921t4y.js} +8 -8
- package/dist/{boot-reembed-62p0at2k.js → boot-reembed-ps2aftpb.js} +9 -7
- package/dist/{boot-reembed-xmfp6wdh.js → boot-reembed-znyk3nxb.js} +10 -8
- package/dist/{boot-scrub-logs-rr8q2dms.js → boot-scrub-logs-2ax3xgz1.js} +7 -5
- package/dist/{claude-adapter-2y47mrkd.js → claude-adapter-3ct8kgxw.js} +13 -9
- package/dist/{claude-managed-adapter-gv19tdew.js → claude-managed-adapter-akx4ntf2.js} +2 -2
- package/dist/{cli-t1xze5qh.js → cli-13w2hwkh.js} +1 -1
- package/dist/{cli-efxfnq6q.js → cli-1532szb1.js} +8 -6
- package/dist/{cli-d2tfkcga.js → cli-23d3rseb.js} +33 -17
- package/dist/{cli-nm5gtp1x.js → cli-2a75z9c5.js} +196 -7
- package/dist/{cli-fywms69x.js → cli-2t30rj0d.js} +1 -1
- package/dist/{cli-cvgjwnh1.js → cli-3c3tdgab.js} +10 -10
- package/dist/{cli-pm6xbh84.js → cli-3zzxa9jz.js} +46 -16
- package/dist/{cli-cdeg08wx.js → cli-4k6233gc.js} +1 -1
- package/dist/{cli-t55yw3a1.js → cli-5p2tz05r.js} +2 -2
- package/dist/{cli-0rcyq6d9.js → cli-5z2yhzvd.js} +125 -47
- package/dist/{cli-3kf51j15.js → cli-6zfq31nj.js} +9 -3
- package/dist/{cli-er3a4bb0.js → cli-8m9de10a.js} +6 -1
- package/dist/{cli-gm9cbhbq.js → cli-8t14vg9w.js} +27 -8
- package/dist/{cli-72q8m8gk.js → cli-9nfc2es8.js} +6 -6
- package/dist/{cli-5ze9rv97.js → cli-9ta3pwms.js} +11 -4
- package/dist/{cli-78xwvkhj.js → cli-avr0ns7z.js} +2 -2
- package/dist/{cli-n6h8qt8n.js → cli-c897wyhw.js} +12 -7
- package/dist/{cli-2q72qz2s.js → cli-dgk20ft7.js} +15 -6
- package/dist/cli-et1bbwqf.js +13 -0
- package/dist/{cli-bqw0mt1n.js → cli-f5w27pj3.js} +3 -3
- package/dist/{cli-2wadx7p2.js → cli-f8dre4bz.js} +2 -2
- package/dist/{cli-e7ckcg4v.js → cli-ftwpv451.js} +170 -387
- package/dist/{cli-g6b8x1h8.js → cli-g854s2dw.js} +1 -1
- package/dist/{cli-5kx36evn.js → cli-ggmdxeqa.js} +13 -8
- package/dist/cli-hkwfe3qj.js +530 -0
- package/dist/{cli-5dsbcjjw.js → cli-jqwnqx9m.js} +1 -1
- package/dist/{cli-9dr5qzc0.js → cli-ny122vh4.js} +21 -10
- package/dist/{cli-5v4rw6yd.js → cli-pvzqhbs9.js} +2 -2
- package/dist/cli-pzq5rjym.js +15 -0
- package/dist/{cli-xpxde7rm.js → cli-qes8ef67.js} +48 -30
- package/dist/{cli-sajgpkqg.js → cli-qs9hhsvb.js} +1723 -325
- package/dist/cli-qys3rhhz.js +27 -0
- package/dist/{cli-rzbpmxdr.js → cli-r6tpm37h.js} +2 -2
- package/dist/{cli-qv61hp37.js → cli-rch80c76.js} +2 -2
- package/dist/{cli-0258203w.js → cli-rpp0x4zy.js} +137 -17
- package/dist/{cli-5n6e5yfn.js → cli-s1k0wxhe.js} +12 -7
- package/dist/{cli-psh3c4va.js → cli-sfd9v7sj.js} +1 -1
- package/dist/{cli-70xqj0z3.js → cli-sh3ytsyj.js} +49 -39
- package/dist/{cli-zsspvvm2.js → cli-w1mp9jy7.js} +1 -1
- package/dist/{cli-d3x3x1c9.js → cli-xdb4mpnp.js} +2 -2
- package/dist/{cli-57khth04.js → cli-xmyqv00z.js} +120 -4
- package/dist/{cli-yw8vghx2.js → cli-xv8t8p3c.js} +65 -32
- package/dist/{cli-f1am9szv.js → cli-xxv5pn4f.js} +1 -1
- package/dist/{cli-t76vw5db.js → cli-y08jvxzx.js} +601 -140
- package/dist/{cli-a4jzdpa3.js → cli-zfbf3srj.js} +650 -245
- package/dist/{cli-a40rnf71.js → cli-zmq89k34.js} +6 -6
- package/dist/{cli-qmdnhpej.js → cli-zr9q011v.js} +23 -13
- package/dist/cli.js +22 -22
- package/dist/{codex-adapter-d24q75wk.js → codex-adapter-bef93pq4.js} +9 -7
- package/dist/{codex-hook-hq54t9ft.js → codex-hook-e8dba65p.js} +3 -2
- package/dist/{codex-session-runner-g50vcsex.js → codex-session-runner-5pkxys7x.js} +9 -7
- package/dist/{commands-c3nrc5hg.js → commands-e23s1bff.js} +9 -7
- package/dist/{db-nt7fg3tw.js → db-m3nabatn.js} +17 -5
- package/dist/{google-generative-ai-hysrd8qx.js → google-generative-ai-8maw55xh.js} +11 -8
- package/dist/{google-vertex-p5arrga0.js → google-vertex-8vjqmx8g.js} +11 -8
- package/dist/{handlers-fkr6swat.js → handlers-akazedvr.js} +16 -13
- package/dist/{hook-77wpvnjc.js → hook-ze4cpf0v.js} +55 -41
- package/dist/{hook-g019yehy.js → hook-zfqyyenw.js} +10 -7
- package/dist/{http-jewj0syk.js → http-sja0n6kw.js} +730 -150
- package/dist/index-0gr8vd6k.js +39 -0
- package/dist/{index-7n2xpbd4.js → index-354vkhdv.js} +154 -74
- package/dist/{index-070v4eyb.js → index-5qthek5t.js} +2 -3
- package/dist/{index-hfvggd0z.js → index-95t3cqq5.js} +16 -14
- package/dist/{index-9znhar3x.js → index-sea66619.js} +3 -4
- package/dist/{index-6gm5he8h.js → index-vpn36mma.js} +15 -13
- package/dist/{keepalive-j0fjvqfd.js → keepalive-3sp3meqj.js} +16 -10
- package/dist/{lead-1n8x30xe.js → lead-mefeewkf.js} +39 -36
- package/dist/{maintenance-gvev11sa.js → maintenance-8tbrbjfg.js} +10 -8
- package/dist/mistral-conversations-00zrp7g4.js +717 -0
- package/dist/{oauth-refresh-sweep-s9ecsvh0.js → oauth-refresh-sweep-srjwjz6m.js} +8 -6
- package/dist/{onboard-pvm5b38w.js → onboard-mt2tgyjg.js} +7 -7
- package/dist/{openai-codex-responses-5g27pd6f.js → openai-codex-responses-map32h2s.js} +18 -18
- package/dist/{openai-completions-3vkmfz2a.js → openai-completions-w871b1cq.js} +12 -11
- package/dist/{openai-responses-dg4t7j7a.js → openai-responses-b0j7xnw8.js} +15 -12
- package/dist/{opencode-adapter-1gm38aba.js → opencode-adapter-b39jefcj.js} +5 -3
- package/dist/{openrouter-images-g3gm8931.js → openrouter-images-srfkywy0.js} +4 -4
- package/dist/{otel-impl-p6jvgd9c.js → otel-impl-z6pxt3j9.js} +224 -9
- package/dist/{pi-mono-adapter-t091s27g.js → pi-mono-adapter-btvqmj58.js} +1138 -408
- package/dist/{pricing-refresh-k49rcyed.js → pricing-refresh-kyq8cezh.js} +9 -7
- package/dist/{rbac-roles-cr80g96t.js → rbac-roles-3ryfrfna.js} +8 -6
- package/dist/{rbac-roles-30pspvy7.js → rbac-roles-6qfvbee1.js} +9 -7
- package/dist/{render-v2-xm457ng1.js → render-v2-p61ppjey.js} +8 -6
- package/dist/{seed-pricing-yhpv35fr.js → seed-pricing-j56bbj4y.js} +8 -6
- package/dist/{setup-nytnmwwr.js → setup-tpr6c1kn.js} +2 -2
- package/dist/{worker-msytr2cw.js → worker-9w1v0mbb.js} +39 -36
- package/openapi.json +542 -16
- package/package.json +6 -6
- package/src/be/budget-refusal-notify.ts +14 -7
- package/src/be/db-queries/task-artifacts.ts +182 -0
- package/src/be/db.ts +783 -169
- package/src/be/events.ts +5 -0
- package/src/be/migrations/130_scratch_script_retention_grace.sql +6 -0
- package/src/be/migrations/131_session_logs_created_at_index.sql +2 -0
- package/src/be/migrations/132_multi_runtime_instances.sql +44 -0
- package/src/be/migrations/133_workflow_run_trigger_type.sql +12 -0
- package/src/be/migrations/135_backfill_task_pull_request_attachments.sql +228 -0
- package/src/be/migrations/136_task_requester_provenance.sql +37 -0
- package/src/be/migrations/137_memory_retrieval_composite_index.sql +1 -0
- package/src/be/multi-runtime.ts +430 -0
- package/src/be/scripts/db.ts +43 -1
- package/src/be/scripts/extract-schema.ts +3 -1
- package/src/be/scripts/retention.ts +164 -0
- package/src/be/scripts/run-saved.ts +16 -1
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-skills/index.ts +7 -0
- package/src/be/steering.ts +1 -1
- package/src/be/swarm-config-guard.ts +18 -0
- package/src/commands/credential-wait.ts +54 -0
- package/src/commands/onboard/steps/core-credentials.tsx +1 -1
- package/src/commands/onboard/steps/custom-templates.tsx +1 -1
- package/src/commands/onboard/steps/harness-credentials.tsx +1 -1
- package/src/commands/onboard/steps/health-check.tsx +1 -1
- package/src/commands/onboard/steps/start.tsx +1 -1
- package/src/commands/profile-sync-audit.ts +56 -0
- package/src/commands/profile-sync.ts +287 -15
- package/src/commands/provider-credentials.ts +46 -19
- package/src/commands/runner.ts +196 -33
- package/src/commands/templates.ts +22 -0
- package/src/github/handlers.ts +55 -9
- package/src/heartbeat/heartbeat.ts +71 -12
- package/src/hooks/hook.ts +49 -33
- package/src/http/active-sessions.ts +11 -1
- package/src/http/agents.ts +261 -30
- package/src/http/all-routes.ts +1 -0
- package/src/http/config.ts +13 -2
- package/src/http/core.ts +102 -4
- package/src/http/db-query-bounded.ts +313 -0
- package/src/http/db-query-shared.ts +249 -0
- package/src/http/db-query.ts +54 -42
- package/src/http/events.ts +2 -0
- package/src/http/index.ts +27 -2
- package/src/http/mcp-bridge.ts +37 -3
- package/src/http/mcp.ts +12 -0
- package/src/http/memory.ts +13 -2
- package/src/http/openapi.ts +1 -0
- package/src/http/poll.ts +70 -27
- package/src/http/route-def.ts +43 -9
- package/src/http/schedules.ts +2 -2
- package/src/http/scripts.ts +31 -4
- package/src/http/session-data.ts +44 -0
- package/src/http/tasks.ts +2 -2
- package/src/http/users.ts +74 -52
- package/src/http/webhooks.ts +5 -2
- package/src/http/workflows.ts +1 -4
- package/src/http/x.ts +14 -0
- package/src/integrations/kapso/inbound.ts +1 -1
- package/src/jira/outbound.ts +39 -8
- package/src/jira/webhook-lifecycle.ts +13 -2
- package/src/linear/outbound.ts +43 -10
- package/src/oauth/keepalive.ts +13 -2
- package/src/prompts/base-prompt.ts +1 -1
- package/src/prompts/session-templates.ts +45 -7
- package/src/providers/claude-adapter.ts +6 -0
- package/src/providers/claude-managed-models.ts +34 -10
- package/src/providers/codex-adapter.ts +6 -0
- package/src/providers/opencode-adapter.ts +6 -0
- package/src/providers/pi-mono-adapter.ts +8 -0
- package/src/providers/pricing-sources.md +24 -0
- package/src/queue-stall-alarm.ts +216 -0
- package/src/scheduler/scheduler.ts +7 -0
- package/src/script-workflows/executor.ts +2 -1
- package/src/scripts-runtime/executors/native.ts +2 -1
- package/src/scripts-runtime/executors/types.ts +5 -0
- package/src/scripts-runtime/loader.ts +5 -0
- package/src/scripts-runtime/swarm-config.ts +8 -0
- package/src/scripts-runtime/swarm-sdk.ts +7 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/tests/budget-claim-gate.test.ts +20 -0
- package/src/tests/claude-adapter.test.ts +32 -0
- package/src/tests/claude-managed-adapter.test.ts +38 -7
- package/src/tests/codex-adapter.test.ts +30 -0
- package/src/tests/create-task-validation.test.ts +128 -0
- package/src/tests/credential-wait.test.ts +67 -1
- package/src/tests/db-query.test.ts +711 -2
- package/src/tests/http-api-integration.test.ts +81 -0
- package/src/tests/http-users.test.ts +166 -1
- package/src/tests/identity-field-budget.test.ts +176 -0
- package/src/tests/mcp-tools-user.test.ts +34 -0
- package/src/tests/mcp-user-route.test.ts +17 -0
- package/src/tests/metrics-http.test.ts +44 -0
- package/src/tests/multi-runtime-registration.test.ts +2703 -0
- package/src/tests/openapi-response-contract.test.ts +24 -0
- package/src/tests/openapi-route-headers.test.ts +83 -0
- package/src/tests/opencode-adapter.test.ts +40 -0
- package/src/tests/outbound-listener-rejection.test.ts +138 -0
- package/src/tests/pi-mono-adapter.test.ts +98 -0
- package/src/tests/profile-sync-warning.test.ts +277 -0
- package/src/tests/profile-sync.test.ts +232 -6
- package/src/tests/prompt-template-session.test.ts +32 -3
- package/src/tests/queue-stall-alarm.test.ts +226 -0
- package/src/tests/requester-comms.test.ts +32 -0
- package/src/tests/runner-requester-profile.test.ts +38 -0
- package/src/tests/sandboxed-process.test.ts +45 -0
- package/src/tests/schedule-target-type.test.ts +38 -0
- package/src/tests/script-connections.test.ts +16 -3
- package/src/tests/scripts-external-api.test.ts +8 -3
- package/src/tests/scripts-http.test.ts +12 -0
- package/src/tests/scripts-retention.test.ts +384 -0
- package/src/tests/scripts-runtime-identity.test.ts +375 -0
- package/src/tests/scripts-runtime.test.ts +4 -3
- package/src/tests/sdk-allowlist.test.ts +47 -0
- package/src/tests/send-task-requested-by.test.ts +9 -0
- package/src/tests/session-costs.test.ts +506 -1
- package/src/tests/shutdown-error-scrubbing.test.ts +92 -0
- package/src/tests/steering-core.test.ts +28 -0
- package/src/tests/task-artifacts.test.ts +386 -0
- package/src/tests/task-attachments-schema.test.ts +17 -1
- package/src/tests/task-completion-idempotency.test.ts +57 -0
- package/src/tests/task-pull-request-backfill-migration.test.ts +321 -0
- package/src/tests/update-profile-api.test.ts +67 -39
- package/src/tests/update-profile-auth.test.ts +48 -4
- package/src/tests/user-attribution-migration.test.ts +54 -0
- package/src/tests/user-identity.test.ts +72 -0
- package/src/tests/workflow-async-v2.test.ts +39 -1
- package/src/tests/workflow-http-v2.test.ts +3 -2
- package/src/tests/workflow-mcp-trigger-schema.test.ts +52 -3
- package/src/tests/workflow-retry-v2.test.ts +45 -1
- package/src/tests/workflow-swarm-script.test.ts +14 -5
- package/src/tools/db-query.ts +18 -8
- package/src/tools/get-task-details.ts +24 -3
- package/src/tools/manage-user.ts +36 -3
- package/src/tools/poll-task.ts +93 -14
- package/src/tools/script-common.ts +5 -0
- package/src/tools/send-task.ts +1 -4
- package/src/tools/store-progress.ts +13 -2
- package/src/tools/swarm-config/delete-config.ts +9 -0
- package/src/tools/swarm-config/set-config.ts +3 -2
- package/src/tools/task-action.ts +30 -0
- package/src/tools/task-tool-ctx.ts +8 -1
- package/src/tools/update-profile.ts +16 -21
- package/src/tools/utils.ts +5 -0
- package/src/tools/workflows/trigger-workflow.ts +7 -1
- package/src/types.ts +165 -1
- package/src/utils/github-pull-request.ts +76 -0
- package/src/utils/identity-field-budget.ts +73 -0
- package/src/utils/multi-runtime.ts +20 -0
- package/src/utils/requester-comms.ts +24 -0
- package/src/utils/sandboxed-process.ts +40 -14
- package/src/workflows/engine.ts +22 -2
- package/src/workflows/executors/swarm-script.ts +24 -1
- package/src/workflows/resume.ts +19 -11
- package/src/workflows/retry-poller.ts +1 -0
- package/src/workflows/triggers.ts +4 -0
- package/templates/schedules/daily-compounding-reflection/content.md +12 -3
- package/templates/skills/db-query-guidance/config.json +14 -0
- package/templates/skills/db-query-guidance/content.md +57 -0
- package/dist/cli-3te4jdkm.js +0 -225
- package/dist/cli-bmze986d.js +0 -7
- package/dist/cli-kh3ba2zf.js +0 -607
- package/dist/index-hp1kdbjj.js +0 -36
- package/dist/mistral-conversations-81fwav0j.js +0 -30578
- package/dist/otel-nqa12wf3.js +0 -3149
- package/dist/{pi-messages-tqj8q6b9.js → pi-messages-sssy32x4.js} +3 -3
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for multi-runtime agents: the runtime-instance lifecycle and the
|
|
3
|
+
* logical task-concurrency policy that sits above it.
|
|
4
|
+
*
|
|
5
|
+
* Server-side by design — these helpers own SQLite statements, so they live
|
|
6
|
+
* under `src/be` rather than beside the DB-free flag reader in
|
|
7
|
+
* `src/utils/multi-runtime.ts`, keeping the worker/API database boundary
|
|
8
|
+
* intact (`scripts/check-db-boundary.sh`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Agent, RuntimeInstance, RuntimeInstanceStatus, SwarmConfig } from "../types";
|
|
12
|
+
import { isMultiRuntimeEnabled } from "../utils/multi-runtime";
|
|
13
|
+
import {
|
|
14
|
+
getActiveTaskCount,
|
|
15
|
+
getAgentById,
|
|
16
|
+
getDb,
|
|
17
|
+
getSwarmConfigs,
|
|
18
|
+
updateAgentMaxTasks,
|
|
19
|
+
updateAgentStatus,
|
|
20
|
+
upsertSwarmConfig,
|
|
21
|
+
} from "./db";
|
|
22
|
+
|
|
23
|
+
// ─── Logical task policy ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Agent-scoped swarm_config key holding the logical maxTasks policy in
|
|
27
|
+
* multi-runtime mode. Operators edit this row; `agents.maxTasks` stays the
|
|
28
|
+
* enforcement mirror that hasCapacity()/getRemainingCapacity() read. Same
|
|
29
|
+
* column-vs-config split as HARNESS_PROVIDER.
|
|
30
|
+
*/
|
|
31
|
+
export const AGENT_MAX_TASKS_CONFIG_KEY = "AGENT_MAX_TASKS";
|
|
32
|
+
|
|
33
|
+
/** Bounds match AgentSchema.maxTasks, so a mirrored write stays schema-valid. */
|
|
34
|
+
function parseAgentMaxTasksValue(raw: string): number | null {
|
|
35
|
+
const str = raw.trim();
|
|
36
|
+
if (!/^\d+$/.test(str)) return null;
|
|
37
|
+
const value = Number(str);
|
|
38
|
+
return value >= 1 && value <= 100 ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Concurrency an agent falls back to with no explicit policy. Mirrors the
|
|
43
|
+
* role defaults the runner resolves for itself (`resolveMaxConcurrent`), so a
|
|
44
|
+
* lead keeps room for the follow-up work it waits on.
|
|
45
|
+
*/
|
|
46
|
+
export function defaultMaxTasksForAgent(agent: Pick<Agent, "isLead">): number {
|
|
47
|
+
return agent.isLead ? 2 : 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getAgentMaxTasksConfig(agentId: string): SwarmConfig | null {
|
|
51
|
+
return (
|
|
52
|
+
getSwarmConfigs({ scope: "agent", scopeId: agentId, key: AGENT_MAX_TASKS_CONFIG_KEY })[0] ??
|
|
53
|
+
null
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reconcile the AGENT_MAX_TASKS policy row with its agents.maxTasks mirror,
|
|
59
|
+
* never consuming the runtime-reported concurrency.
|
|
60
|
+
*
|
|
61
|
+
* With no row yet, seed from the persisted agents.maxTasks — the value the
|
|
62
|
+
* swarm is already enforcing — so enabling multi-runtime mode cannot change
|
|
63
|
+
* behaviour. An existing row is authoritative and repairs the mirror instead.
|
|
64
|
+
*
|
|
65
|
+
* Runs inside the registration transaction so concurrent runtimes serialize:
|
|
66
|
+
* the first seeds, the rest observe.
|
|
67
|
+
*/
|
|
68
|
+
export function reconcileAgentMaxTasksPolicy(agentId: string): void {
|
|
69
|
+
const agent = getAgentById(agentId);
|
|
70
|
+
if (!agent) return;
|
|
71
|
+
const existing = getAgentMaxTasksConfig(agentId);
|
|
72
|
+
if (existing) {
|
|
73
|
+
const authoritative = parseAgentMaxTasksValue(existing.value);
|
|
74
|
+
if (authoritative !== null && authoritative !== (agent.maxTasks ?? 1)) {
|
|
75
|
+
updateAgentMaxTasks(agentId, authoritative);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
upsertSwarmConfig({
|
|
80
|
+
scope: "agent",
|
|
81
|
+
scopeId: agentId,
|
|
82
|
+
key: AGENT_MAX_TASKS_CONFIG_KEY,
|
|
83
|
+
value: String(agent.maxTasks ?? defaultMaxTasksForAgent(agent)),
|
|
84
|
+
description: "Logical agent max-tasks policy (seeded from agents.maxTasks)",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Deleting the policy returns the agent to its role default rather than to a
|
|
90
|
+
* fixed single slot — otherwise resetting a lead would leave it unable to
|
|
91
|
+
* start the follow-up its own task waits on.
|
|
92
|
+
*/
|
|
93
|
+
export function resetAgentMaxTasksMirror(agentId: string): void {
|
|
94
|
+
const agent = getAgentById(agentId);
|
|
95
|
+
if (!agent) return;
|
|
96
|
+
updateAgentMaxTasks(agentId, defaultMaxTasksForAgent(agent));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Config upsert that keeps the agents.maxTasks enforcement mirror in step
|
|
101
|
+
* with an agent-scoped AGENT_MAX_TASKS write, in one transaction, so policy
|
|
102
|
+
* and enforcement cannot diverge — including with no runtime connected.
|
|
103
|
+
* Every other key passes straight through.
|
|
104
|
+
*/
|
|
105
|
+
export function upsertSwarmConfigWithPolicyMirror(
|
|
106
|
+
data: Parameters<typeof upsertSwarmConfig>[0],
|
|
107
|
+
): SwarmConfig {
|
|
108
|
+
const scopeId = data.scope === "agent" ? data.scopeId : null;
|
|
109
|
+
if (!scopeId || data.key !== AGENT_MAX_TASKS_CONFIG_KEY) {
|
|
110
|
+
return upsertSwarmConfig(data);
|
|
111
|
+
}
|
|
112
|
+
return getDb().transaction(() => {
|
|
113
|
+
const row = upsertSwarmConfig(data);
|
|
114
|
+
// Guard, not validation — both write paths already ran validateConfigValue.
|
|
115
|
+
const value = parseAgentMaxTasksValue(data.value);
|
|
116
|
+
if (value !== null) updateAgentMaxTasks(scopeId, value);
|
|
117
|
+
return row;
|
|
118
|
+
})();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Runtime instances ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
interface RuntimeInstanceRow {
|
|
124
|
+
id: string;
|
|
125
|
+
agent_id: string;
|
|
126
|
+
status: string;
|
|
127
|
+
reported_slots: number;
|
|
128
|
+
credential_ready: number | null;
|
|
129
|
+
metadata: string | null;
|
|
130
|
+
last_seen_at: string;
|
|
131
|
+
created_at: string;
|
|
132
|
+
updated_at: string;
|
|
133
|
+
created_by: string | null;
|
|
134
|
+
updated_by: string | null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function rowToRuntimeInstance(row: RuntimeInstanceRow): RuntimeInstance {
|
|
138
|
+
let metadata: Record<string, unknown> | null = null;
|
|
139
|
+
if (row.metadata) {
|
|
140
|
+
try {
|
|
141
|
+
metadata = JSON.parse(row.metadata) as Record<string, unknown>;
|
|
142
|
+
} catch {
|
|
143
|
+
metadata = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
id: row.id,
|
|
148
|
+
agentId: row.agent_id,
|
|
149
|
+
status: row.status as RuntimeInstanceStatus,
|
|
150
|
+
reportedSlots: row.reported_slots,
|
|
151
|
+
credentialReady: row.credential_ready === null ? null : row.credential_ready === 1,
|
|
152
|
+
metadata,
|
|
153
|
+
lastSeenAt: row.last_seen_at,
|
|
154
|
+
createdAt: row.created_at,
|
|
155
|
+
updatedAt: row.updated_at,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Minutes without a ping before a runtime stops counting as live. The runner
|
|
161
|
+
* pings every poll iteration (~2s), so this is a wide margin that will not
|
|
162
|
+
* expire a healthy idle worker; it matches the "worker clearly dead" window
|
|
163
|
+
* the stalled-task classifier already uses.
|
|
164
|
+
*/
|
|
165
|
+
export function runtimeStaleThresholdMinutes(): number {
|
|
166
|
+
return Number(process.env.RUNTIME_STALE_THRESHOLD_MIN) || 5;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function runtimeLivenessCutoff(): string {
|
|
170
|
+
return new Date(Date.now() - runtimeStaleThresholdMinutes() * 60 * 1000).toISOString();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Register (or refresh) a runtime instance serving a logical agent. A
|
|
175
|
+
* re-registration from the same process refreshes its row in place rather
|
|
176
|
+
* than accumulating rows.
|
|
177
|
+
*
|
|
178
|
+
* The `DO UPDATE ... WHERE` guard makes runtime ownership permanent: an id
|
|
179
|
+
* already held by one agent can never be reassigned to another, so a
|
|
180
|
+
* duplicate id cannot move a live runtime between agents. Returns null in
|
|
181
|
+
* that case (no row written).
|
|
182
|
+
*/
|
|
183
|
+
export function upsertRuntimeInstance(instance: {
|
|
184
|
+
id: string;
|
|
185
|
+
agentId: string;
|
|
186
|
+
reportedSlots: number;
|
|
187
|
+
metadata?: Record<string, unknown> | null;
|
|
188
|
+
}): RuntimeInstance | null {
|
|
189
|
+
const metadataJson = instance.metadata ? JSON.stringify(instance.metadata) : null;
|
|
190
|
+
const row = getDb()
|
|
191
|
+
.prepare<RuntimeInstanceRow, [string, string, number, string | null]>(
|
|
192
|
+
`INSERT INTO runtime_instances (id, agent_id, status, reported_slots, metadata)
|
|
193
|
+
VALUES (?, ?, 'active', ?, ?)
|
|
194
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
195
|
+
status = 'active',
|
|
196
|
+
reported_slots = excluded.reported_slots,
|
|
197
|
+
metadata = COALESCE(excluded.metadata, runtime_instances.metadata),
|
|
198
|
+
last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
|
199
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
200
|
+
WHERE runtime_instances.agent_id = excluded.agent_id
|
|
201
|
+
RETURNING *`,
|
|
202
|
+
)
|
|
203
|
+
.get(instance.id, instance.agentId, instance.reportedSlots, metadataJson);
|
|
204
|
+
return row ? rowToRuntimeInstance(row) : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function getRuntimeInstanceById(id: string): RuntimeInstance | null {
|
|
208
|
+
const row = getDb()
|
|
209
|
+
.prepare<RuntimeInstanceRow, [string]>("SELECT * FROM runtime_instances WHERE id = ?")
|
|
210
|
+
.get(id);
|
|
211
|
+
return row ? rowToRuntimeInstance(row) : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Refresh a live runtime instance's liveness from the worker's ping cadence.
|
|
216
|
+
*
|
|
217
|
+
* Matches only a row that is already live and owned by the caller, so worker
|
|
218
|
+
* traffic can refresh liveness but never resurrect a closed or expired
|
|
219
|
+
* runtime — registration stays the only path back to `active`. Returns false
|
|
220
|
+
* when nothing matched, which callers use as "this identity is not live".
|
|
221
|
+
*/
|
|
222
|
+
export function touchRuntimeInstance(id: string, agentId: string): boolean {
|
|
223
|
+
const result = getDb()
|
|
224
|
+
.prepare(
|
|
225
|
+
`UPDATE runtime_instances
|
|
226
|
+
SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
|
227
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
228
|
+
WHERE id = ? AND agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
|
|
229
|
+
)
|
|
230
|
+
.run(id, agentId, runtimeLivenessCutoff());
|
|
231
|
+
return result.changes > 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Mark one runtime instance offline (scoped to its agent, like touch). */
|
|
235
|
+
export function markRuntimeInstanceOffline(id: string, agentId: string): RuntimeInstance | null {
|
|
236
|
+
const row = getDb()
|
|
237
|
+
.prepare<RuntimeInstanceRow, [string, string]>(
|
|
238
|
+
`UPDATE runtime_instances
|
|
239
|
+
SET status = 'offline', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
240
|
+
WHERE id = ? AND agent_id = ? RETURNING *`,
|
|
241
|
+
)
|
|
242
|
+
.get(id, agentId);
|
|
243
|
+
return row ? rowToRuntimeInstance(row) : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Runtimes currently serving an agent. A crashed process never reaches
|
|
248
|
+
* `/close`, so `active` alone would keep a dead runtime counted forever;
|
|
249
|
+
* liveness is therefore the conjunction of status and a fresh `last_seen_at`.
|
|
250
|
+
*/
|
|
251
|
+
export function countActiveRuntimeInstancesForAgent(agentId: string): number {
|
|
252
|
+
const result = getDb()
|
|
253
|
+
.prepare<{ count: number }, [string, string]>(
|
|
254
|
+
`SELECT COUNT(*) as count FROM runtime_instances
|
|
255
|
+
WHERE agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
|
|
256
|
+
)
|
|
257
|
+
.get(agentId, runtimeLivenessCutoff());
|
|
258
|
+
return result?.count ?? 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read-only listing for operator observability. `isLive` applies the same
|
|
263
|
+
* status+freshness conjunction as `isRuntimeInstanceLive`, so a stale row
|
|
264
|
+
* that the sweep has not pruned yet is never reported as live.
|
|
265
|
+
*/
|
|
266
|
+
export function listRuntimeInstancesForAgent(
|
|
267
|
+
agentId: string,
|
|
268
|
+
): Array<RuntimeInstance & { isLive: boolean }> {
|
|
269
|
+
const rows = getDb()
|
|
270
|
+
.prepare<RuntimeInstanceRow & { is_live: number }, [string, string]>(
|
|
271
|
+
`SELECT *, (status = 'active' AND last_seen_at >= ?) AS is_live
|
|
272
|
+
FROM runtime_instances
|
|
273
|
+
WHERE agent_id = ?
|
|
274
|
+
ORDER BY created_at ASC, id ASC`,
|
|
275
|
+
)
|
|
276
|
+
.all(runtimeLivenessCutoff(), agentId);
|
|
277
|
+
return rows.map((row) => ({ ...rowToRuntimeInstance(row), isLive: row.is_live === 1 }));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Whether this identity names a runtime of `agentId` that is still reporting.
|
|
282
|
+
* Dispatch uses it so a retired process cannot be handed work alongside its
|
|
283
|
+
* replacement; an absent identity is never live.
|
|
284
|
+
*/
|
|
285
|
+
export function isRuntimeInstanceLive(id: string | undefined, agentId: string): boolean {
|
|
286
|
+
if (!id) return false;
|
|
287
|
+
const row = getDb()
|
|
288
|
+
.prepare<{ c: number }, [string, string, string]>(
|
|
289
|
+
`SELECT COUNT(*) c FROM runtime_instances
|
|
290
|
+
WHERE id = ? AND agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
|
|
291
|
+
)
|
|
292
|
+
.get(id, agentId, runtimeLivenessCutoff());
|
|
293
|
+
return (row?.c ?? 0) > 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Record this runtime's credential readiness. Process-local by design: a
|
|
298
|
+
* sibling's state is never touched. Only a live runtime may report.
|
|
299
|
+
*/
|
|
300
|
+
export function setRuntimeCredentialReady(id: string, agentId: string, ready: boolean): boolean {
|
|
301
|
+
const result = getDb()
|
|
302
|
+
.prepare(
|
|
303
|
+
`UPDATE runtime_instances
|
|
304
|
+
SET credential_ready = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
305
|
+
WHERE id = ? AND agent_id = ? AND status = 'active' AND last_seen_at >= ?`,
|
|
306
|
+
)
|
|
307
|
+
.run(ready ? 1 : 0, id, agentId, runtimeLivenessCutoff());
|
|
308
|
+
return result.changes > 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Whether any live runtime can currently execute work. A runtime that never
|
|
313
|
+
* reported (NULL) counts as ready, so workers opting out of credential checks
|
|
314
|
+
* stay dispatchable.
|
|
315
|
+
*/
|
|
316
|
+
export function hasReadyLiveRuntime(agentId: string): boolean {
|
|
317
|
+
const row = getDb()
|
|
318
|
+
.prepare<{ c: number }, [string, string]>(
|
|
319
|
+
`SELECT COUNT(*) c FROM runtime_instances
|
|
320
|
+
WHERE agent_id = ? AND status = 'active' AND last_seen_at >= ?
|
|
321
|
+
AND (credential_ready IS NULL OR credential_ready = 1)`,
|
|
322
|
+
)
|
|
323
|
+
.get(agentId, runtimeLivenessCutoff());
|
|
324
|
+
return (row?.c ?? 0) > 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Recompute the logical agent's state from its live runtimes.
|
|
329
|
+
*
|
|
330
|
+
* Precedence: no live runtime wins (offline), then active work (busy), then
|
|
331
|
+
* credential availability, then idle. Aggregating here is what stops one
|
|
332
|
+
* process's report from overwriting a sibling's — a waiting runtime cannot
|
|
333
|
+
* park an agent a ready sibling is serving, and a ready one cannot pull a
|
|
334
|
+
* busy agent back to idle.
|
|
335
|
+
*/
|
|
336
|
+
export function reconcileAgentStatusFromRuntimes(agentId: string): void {
|
|
337
|
+
const agent = getAgentById(agentId);
|
|
338
|
+
if (!agent) return;
|
|
339
|
+
if (countActiveRuntimeInstancesForAgent(agentId) === 0) {
|
|
340
|
+
if (agent.status !== "offline") updateAgentStatus(agentId, "offline");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (!hasReadyLiveRuntime(agentId)) {
|
|
344
|
+
if (agent.status !== "waiting_for_credentials") {
|
|
345
|
+
updateAgentStatus(agentId, "waiting_for_credentials");
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const next = getActiveTaskCount(agentId) > 0 ? "busy" : "idle";
|
|
350
|
+
if (agent.status !== next) updateAgentStatus(agentId, next);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Agents with at least one runtime still reporting. While multi-runtime mode
|
|
355
|
+
* is on this is the eligibility set for dispatch: an agent with no live
|
|
356
|
+
* runtime — whether its runtimes died or it has not re-registered since the
|
|
357
|
+
* flag was enabled — cannot poll for work, so assigning to it would strand
|
|
358
|
+
* the task.
|
|
359
|
+
*/
|
|
360
|
+
export function agentsWithLiveRuntime(): Set<string> {
|
|
361
|
+
const rows = getDb()
|
|
362
|
+
.prepare<{ agent_id: string }, [string]>(
|
|
363
|
+
`SELECT DISTINCT agent_id FROM runtime_instances
|
|
364
|
+
WHERE status = 'active' AND last_seen_at >= ?`,
|
|
365
|
+
)
|
|
366
|
+
.all(runtimeLivenessCutoff());
|
|
367
|
+
return new Set(rows.map((r) => r.agent_id));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Retire runtimes that stopped pinging and take their agents offline when
|
|
372
|
+
* nothing live remains.
|
|
373
|
+
*
|
|
374
|
+
* Inert unless multi-runtime mode is on: after a rollback, workers stop
|
|
375
|
+
* refreshing their rows, and expiring them would offline agents that are
|
|
376
|
+
* running perfectly well under legacy semantics.
|
|
377
|
+
*
|
|
378
|
+
* Deleting the retired rows rather than keeping them is deliberate. Runtime
|
|
379
|
+
* identity is per boot, so retaining them would grow the table once per boot
|
|
380
|
+
* per agent forever; nothing reads a runtime after it stops being live.
|
|
381
|
+
*
|
|
382
|
+
* Expiry deliberately does NOT touch active_sessions. Runtime liveness and
|
|
383
|
+
* in-flight work liveness answer different questions: a stale runtime row is
|
|
384
|
+
* enough to stop NEW dispatch, but sessions are heartbeated by tool activity
|
|
385
|
+
* only — a healthy worker inside a long model call or shell command can be
|
|
386
|
+
* quiet past this cutoff — so it is not evidence the CURRENT work is dead.
|
|
387
|
+
* Crash classification stays owned by the heartbeat's stalled-task
|
|
388
|
+
* remediation, which requires both a stale session heartbeat and a stale
|
|
389
|
+
* task before reclaiming (and cleans the session when it does); the sweep's
|
|
390
|
+
* 30-minute stale-session cleanup backstops any leftover row.
|
|
391
|
+
*/
|
|
392
|
+
export function expireStaleRuntimeInstances(): {
|
|
393
|
+
expired: number;
|
|
394
|
+
agentsOffline: number;
|
|
395
|
+
pruned: number;
|
|
396
|
+
} {
|
|
397
|
+
if (!isMultiRuntimeEnabled()) {
|
|
398
|
+
return { expired: 0, agentsOffline: 0, pruned: 0 };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return getDb().transaction(() => {
|
|
402
|
+
const cutoff = runtimeLivenessCutoff();
|
|
403
|
+
// Still-active rows past the window never reached /close; retiring them
|
|
404
|
+
// removes the process from new-work eligibility. Whether its in-flight
|
|
405
|
+
// work is dead is decided separately (see doc comment above).
|
|
406
|
+
const stale = getDb()
|
|
407
|
+
.prepare<{ id: string; agent_id: string }, [string]>(
|
|
408
|
+
`SELECT id, agent_id FROM runtime_instances
|
|
409
|
+
WHERE status = 'active' AND last_seen_at < ?`,
|
|
410
|
+
)
|
|
411
|
+
.all(cutoff);
|
|
412
|
+
|
|
413
|
+
// Prune every row past the window, closed ones included. Runtime identity
|
|
414
|
+
// is per boot, so keeping them would add a row per boot per agent forever
|
|
415
|
+
// and nothing reads a runtime once it stops being live.
|
|
416
|
+
const pruned = getDb()
|
|
417
|
+
.prepare("DELETE FROM runtime_instances WHERE last_seen_at < ?")
|
|
418
|
+
.run(cutoff).changes;
|
|
419
|
+
|
|
420
|
+
// Recompute every affected agent from what survived: losing the only
|
|
421
|
+
// ready runtime has to park the agent even though a waiting sibling is
|
|
422
|
+
// still live, or the same sweep would assign work it cannot execute.
|
|
423
|
+
let agentsOffline = 0;
|
|
424
|
+
for (const agentId of new Set(stale.map((r) => r.agent_id))) {
|
|
425
|
+
reconcileAgentStatusFromRuntimes(agentId);
|
|
426
|
+
if (getAgentById(agentId)?.status === "offline") agentsOffline++;
|
|
427
|
+
}
|
|
428
|
+
return { expired: stale.length, agentsOffline, pruned };
|
|
429
|
+
})();
|
|
430
|
+
}
|
package/src/be/scripts/db.ts
CHANGED
|
@@ -226,11 +226,17 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
|
|
|
226
226
|
args.signatureJson !== existing.signatureJson ||
|
|
227
227
|
argsJsonSchema !== existing.argsJsonSchema;
|
|
228
228
|
const promotedFromScratch = existing.isScratch && !isScratch;
|
|
229
|
+
const refreshScratchLastUsed =
|
|
230
|
+
existing.scope === "agent" &&
|
|
231
|
+
existing.name.startsWith("scratch-") &&
|
|
232
|
+
existing.isScratch &&
|
|
233
|
+
isScratch;
|
|
229
234
|
if (
|
|
230
235
|
fsMode !== existing.fsMode ||
|
|
231
236
|
isScratch !== existing.isScratch ||
|
|
232
237
|
typeChecked !== existing.typeChecked ||
|
|
233
|
-
trackedMetadataChanged
|
|
238
|
+
trackedMetadataChanged ||
|
|
239
|
+
refreshScratchLastUsed
|
|
234
240
|
) {
|
|
235
241
|
const row = getDb()
|
|
236
242
|
.prepare<
|
|
@@ -466,6 +472,42 @@ export function deleteScript(args: ScriptIdentity): boolean {
|
|
|
466
472
|
return result.changes > 0;
|
|
467
473
|
}
|
|
468
474
|
|
|
475
|
+
/** Bumps updatedAt and returns the timestamp actually written, or null if the row didn't match. */
|
|
476
|
+
export function touchScratchScriptLastUsed(
|
|
477
|
+
id: string,
|
|
478
|
+
at: string = new Date().toISOString(),
|
|
479
|
+
): string | null {
|
|
480
|
+
const result = getDb()
|
|
481
|
+
.prepare(
|
|
482
|
+
`UPDATE scripts SET updatedAt = ?
|
|
483
|
+
WHERE id = ? AND scope = 'agent' AND isScratch = 1 AND name GLOB 'scratch-*'`,
|
|
484
|
+
)
|
|
485
|
+
.run(at, id);
|
|
486
|
+
return result.changes > 0 ? at : null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Rolls back an in-flight run-start touch after the run turns out to have
|
|
491
|
+
* failed, so a failed invocation doesn't extend the retention window the way
|
|
492
|
+
* a successful one does. Only applies if nothing has touched the row since
|
|
493
|
+
* (CAS on updatedAt) — a concurrent run's touch, in-flight or successful,
|
|
494
|
+
* always wins over this rollback.
|
|
495
|
+
*/
|
|
496
|
+
export function restoreScratchScriptLastUsedIfUnchanged(
|
|
497
|
+
id: string,
|
|
498
|
+
priorUpdatedAt: string,
|
|
499
|
+
expectedUpdatedAt: string,
|
|
500
|
+
): boolean {
|
|
501
|
+
return (
|
|
502
|
+
getDb()
|
|
503
|
+
.prepare(
|
|
504
|
+
`UPDATE scripts SET updatedAt = ?
|
|
505
|
+
WHERE id = ? AND scope = 'agent' AND isScratch = 1 AND name GLOB 'scratch-*' AND updatedAt = ?`,
|
|
506
|
+
)
|
|
507
|
+
.run(priorUpdatedAt, id, expectedUpdatedAt).changes > 0
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
469
511
|
// ─── External script APIs (script_apis) ──────────────────────────────────────
|
|
470
512
|
|
|
471
513
|
type ScriptApiRow = {
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* Extraction is best-effort and non-blocking — failures return null.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
10
12
|
const TIMEOUT_MS = 5_000;
|
|
11
13
|
|
|
12
14
|
function extractorPath(): string {
|
|
13
|
-
return new URL("../../scripts-runtime/extract-args-schema.ts", import.meta.url)
|
|
15
|
+
return fileURLToPath(new URL("../../scripts-runtime/extract-args-schema.ts", import.meta.url));
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export async function extractArgsJsonSchema(source: string): Promise<string | null> {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { collectScriptReferences } from "../../apps/definition";
|
|
2
|
+
import { listAppRecords } from "../../apps/store";
|
|
3
|
+
import { getDb, listWorkflows } from "../db";
|
|
4
|
+
|
|
5
|
+
const SCRATCH_RETENTION_DAYS = 14;
|
|
6
|
+
const SCRATCH_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
let scratchGcTimer: ReturnType<typeof setInterval> | null = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every script id wired into an app definition, as an action or model source.
|
|
12
|
+
*
|
|
13
|
+
* `candidateIds` are the GC-candidate scratch scripts this call cares about.
|
|
14
|
+
* A broken app (invalid stored JSON) decodes with `definition` set to the raw
|
|
15
|
+
* string itself, not a merge-patch object, so `collectScriptReferences` below
|
|
16
|
+
* finds nothing for it. Same substring-probe fallback the interactive delete
|
|
17
|
+
* guard applies via `appScriptReferenceIssues` in src/http/scripts.ts — a
|
|
18
|
+
* broken app is not consent to break it further.
|
|
19
|
+
*/
|
|
20
|
+
function appReferencedScriptIds(candidateIds: readonly string[]): Set<string> {
|
|
21
|
+
const ids = new Set<string>();
|
|
22
|
+
for (const app of listAppRecords()) {
|
|
23
|
+
for (const scriptId of collectScriptReferences(app.definition).keys()) {
|
|
24
|
+
ids.add(scriptId);
|
|
25
|
+
}
|
|
26
|
+
if (app.definitionError === undefined) continue;
|
|
27
|
+
const serialized = JSON.stringify(app.definition ?? null);
|
|
28
|
+
for (const id of candidateIds) {
|
|
29
|
+
if (!ids.has(id) && serialized.includes(id)) ids.add(id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return ids;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every script id bound to a durable public API endpoint. `script_apis.scriptId`
|
|
37
|
+
* cascade-deletes with the script (`ON DELETE CASCADE`, src/be/migrations/102_script_apis.sql),
|
|
38
|
+
* so an unreferenced-looking scratch script backing a live `/api/x/script` endpoint
|
|
39
|
+
* would otherwise be silently deleted out from under it.
|
|
40
|
+
*/
|
|
41
|
+
function scriptApiReferencedScriptIds(): Set<string> {
|
|
42
|
+
const rows = getDb()
|
|
43
|
+
.prepare<{ scriptId: string }, []>("SELECT DISTINCT scriptId FROM script_apis")
|
|
44
|
+
.all();
|
|
45
|
+
return new Set(rows.map((row) => row.scriptId));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `(name, agentId)` keys an enabled workflow's `swarm-script` node could resolve at
|
|
50
|
+
* agent scope. A node's `config.scriptName` resolves by NAME, not id
|
|
51
|
+
* (src/workflows/executors/swarm-script.ts `resolveScriptSource`), against the
|
|
52
|
+
* workflow's `createdByAgentId` for `scope: "agent"` or omitted scope — so a
|
|
53
|
+
* name/scope match against a workflow's owner is the only static signal available;
|
|
54
|
+
* `scope: "global"` nodes can never resolve an agent-scoped scratch candidate and
|
|
55
|
+
* are skipped.
|
|
56
|
+
*/
|
|
57
|
+
function workflowReferencedAgentScriptKeys(): Set<string> {
|
|
58
|
+
const keys = new Set<string>();
|
|
59
|
+
for (const workflow of listWorkflows()) {
|
|
60
|
+
if (!workflow.createdByAgentId) continue;
|
|
61
|
+
for (const node of workflow.definition.nodes) {
|
|
62
|
+
if (node.type !== "swarm-script") continue;
|
|
63
|
+
const scriptName = (node.config as { scriptName?: unknown }).scriptName;
|
|
64
|
+
const scope = (node.config as { scope?: unknown }).scope;
|
|
65
|
+
if (typeof scriptName !== "string" || scope === "global") continue;
|
|
66
|
+
keys.add(`${scriptName}::${workflow.createdByAgentId}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return keys;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Script names an enabled `swarm-script` node could resolve at agent scope for a
|
|
74
|
+
* workflow with no static owner (`createdByAgentId` null — no `X-Agent-ID` at
|
|
75
|
+
* creation time, or a pre-migration legacy row). `SwarmScriptExecutor` falls back to
|
|
76
|
+
* `trigger.agentId` at run time for these (src/workflows/executors/swarm-script.ts
|
|
77
|
+
* `agentIdFromContext`), so the resolving agent isn't knowable statically — this
|
|
78
|
+
* excludes by NAME alone across every agent scope, which is over-broad (it also
|
|
79
|
+
* protects a same-named scratch script some *other*, unrelated agent owns) but is
|
|
80
|
+
* the only sound static signal available; `scope: "global"` nodes are skipped for
|
|
81
|
+
* the same reason as the owner-keyed variant above.
|
|
82
|
+
*/
|
|
83
|
+
function workflowReferencedScratchNamesForOwnerlessWorkflows(): Set<string> {
|
|
84
|
+
const names = new Set<string>();
|
|
85
|
+
for (const workflow of listWorkflows()) {
|
|
86
|
+
if (workflow.createdByAgentId) continue;
|
|
87
|
+
for (const node of workflow.definition.nodes) {
|
|
88
|
+
if (node.type !== "swarm-script") continue;
|
|
89
|
+
const scriptName = (node.config as { scriptName?: unknown }).scriptName;
|
|
90
|
+
const scope = (node.config as { scope?: unknown }).scope;
|
|
91
|
+
if (typeof scriptName !== "string" || scope === "global") continue;
|
|
92
|
+
names.add(scriptName);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return names;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Delete auto-saved scratch scripts that have not run within the retention window. */
|
|
99
|
+
export function purgeExpiredScratchScripts(now = new Date()): number {
|
|
100
|
+
const cutoff = new Date(
|
|
101
|
+
now.getTime() - SCRATCH_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
|
102
|
+
).toISOString();
|
|
103
|
+
const candidates = getDb()
|
|
104
|
+
.prepare<{ id: string; name: string; scopeId: string | null }, [string]>(
|
|
105
|
+
`SELECT id, name, scopeId FROM scripts
|
|
106
|
+
WHERE scope = 'agent'
|
|
107
|
+
AND isScratch = 1
|
|
108
|
+
AND name GLOB 'scratch-*'
|
|
109
|
+
AND updatedAt < ?`,
|
|
110
|
+
)
|
|
111
|
+
.all(cutoff);
|
|
112
|
+
if (candidates.length === 0) return 0;
|
|
113
|
+
|
|
114
|
+
// A scratch script still wired into a durable reference — an app action/model
|
|
115
|
+
// source, a public API endpoint, or a workflow's swarm-script node — is no
|
|
116
|
+
// longer "scratch" in effect; deleting it leaves that reference dangling. Same
|
|
117
|
+
// guard the interactive scripts-API delete route enforces for app references via
|
|
118
|
+
// appScriptReferenceIssues, applied here to the whole sweep at once, plus the two
|
|
119
|
+
// durable reference kinds that guard doesn't cover either.
|
|
120
|
+
const referenced = appReferencedScriptIds(candidates.map((row) => row.id));
|
|
121
|
+
const apiReferenced = scriptApiReferencedScriptIds();
|
|
122
|
+
const workflowReferenced = workflowReferencedAgentScriptKeys();
|
|
123
|
+
const ownerlessWorkflowReferencedNames = workflowReferencedScratchNamesForOwnerlessWorkflows();
|
|
124
|
+
const idsToDelete = candidates
|
|
125
|
+
.filter((row) => !referenced.has(row.id))
|
|
126
|
+
.filter((row) => !apiReferenced.has(row.id))
|
|
127
|
+
.filter((row) => !(row.scopeId && workflowReferenced.has(`${row.name}::${row.scopeId}`)))
|
|
128
|
+
.filter((row) => !ownerlessWorkflowReferencedNames.has(row.name))
|
|
129
|
+
.map((row) => row.id);
|
|
130
|
+
if (idsToDelete.length === 0) return 0;
|
|
131
|
+
|
|
132
|
+
const placeholders = idsToDelete.map(() => "?").join(",");
|
|
133
|
+
return (
|
|
134
|
+
getDb()
|
|
135
|
+
.prepare<{ id: string }, string[]>(
|
|
136
|
+
`DELETE FROM scripts WHERE id IN (${placeholders}) RETURNING id`,
|
|
137
|
+
)
|
|
138
|
+
// RETURNING counts scripts only; SQLite's change count includes cascaded rows.
|
|
139
|
+
.all(...idsToDelete).length
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function runScratchScriptGc(label: "Initial" | "Periodic"): void {
|
|
144
|
+
try {
|
|
145
|
+
const purged = purgeExpiredScratchScripts();
|
|
146
|
+
console.log(`[scratch-script-gc] ${label} purge removed ${purged} scratch script row(s)`);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`[scratch-script-gc] ${label} purge failed:`, (err as Error).message);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Start the scratch-script retention GC (daily tick, immediate first run). */
|
|
153
|
+
export function startScratchScriptGc(intervalMs = SCRATCH_GC_INTERVAL_MS): void {
|
|
154
|
+
if (scratchGcTimer) return;
|
|
155
|
+
runScratchScriptGc("Initial");
|
|
156
|
+
scratchGcTimer = setInterval(() => runScratchScriptGc("Periodic"), intervalMs);
|
|
157
|
+
if (typeof scratchGcTimer.unref === "function") scratchGcTimer.unref();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function stopScratchScriptGc(): void {
|
|
161
|
+
if (!scratchGcTimer) return;
|
|
162
|
+
clearInterval(scratchGcTimer);
|
|
163
|
+
scratchGcTimer = null;
|
|
164
|
+
}
|