@desplega.ai/agent-swarm 1.131.0 → 1.131.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/dist/{actions-w53mbns9.js → actions-jgcy477k.js} +6 -6
- package/dist/{app-m9gwqsqv.js → app-5da78bd4.js} +3 -3
- package/dist/{assistant-bp65m6f3.js → assistant-f2vkk1hj.js} +8 -8
- package/dist/{boot-reembed-62p0at2k.js → boot-reembed-q7mp8hcr.js} +3 -3
- package/dist/{boot-reembed-xmfp6wdh.js → boot-reembed-xmw8wcx6.js} +4 -4
- package/dist/{boot-scrub-logs-rr8q2dms.js → boot-scrub-logs-dsqrv79n.js} +2 -2
- package/dist/{claude-adapter-2y47mrkd.js → claude-adapter-q6x7yspx.js} +4 -4
- package/dist/{claude-managed-adapter-gv19tdew.js → claude-managed-adapter-wsg37bt8.js} +1 -1
- package/dist/{cli-t1xze5qh.js → cli-114fvtmj.js} +1 -1
- package/dist/{cli-cdeg08wx.js → cli-2f07m0xr.js} +1 -1
- package/dist/{cli-70xqj0z3.js → cli-2rk0cx7s.js} +2 -2
- package/dist/{cli-nm5gtp1x.js → cli-3ty0t082.js} +1 -1
- package/dist/{cli-efxfnq6q.js → cli-4rkfg1wx.js} +3 -3
- package/dist/{cli-a4jzdpa3.js → cli-50vbt00w.js} +1 -1
- package/dist/{cli-pm6xbh84.js → cli-6dp3ydxa.js} +34 -8
- package/dist/{cli-d3x3x1c9.js → cli-6dpytdr2.js} +1 -1
- package/dist/{cli-e7ckcg4v.js → cli-6pcz9h15.js} +12 -12
- package/dist/{cli-5kx36evn.js → cli-793jvbsh.js} +1 -1
- package/dist/{cli-78xwvkhj.js → cli-7dm2zqy2.js} +2 -2
- package/dist/{cli-psh3c4va.js → cli-aca5pz9f.js} +1 -1
- package/dist/{cli-72q8m8gk.js → cli-ccf9vy7q.js} +2 -2
- package/dist/{cli-qv61hp37.js → cli-g1re8r05.js} +2 -2
- package/dist/{cli-d2tfkcga.js → cli-g74vpjp2.js} +3 -3
- package/dist/{cli-2q72qz2s.js → cli-gecy3z2s.js} +13 -4
- package/dist/{cli-yw8vghx2.js → cli-jbcva0xj.js} +14 -6
- package/dist/{cli-t76vw5db.js → cli-jevgrb4w.js} +19 -19
- package/dist/{cli-3kf51j15.js → cli-kd1ck4rx.js} +1 -1
- package/dist/{cli-g6b8x1h8.js → cli-nrkkknat.js} +1 -1
- package/dist/{cli-fywms69x.js → cli-pxfxkx5m.js} +1 -1
- package/dist/{cli-cvgjwnh1.js → cli-rgrfzg8z.js} +6 -6
- package/dist/{cli-rzbpmxdr.js → cli-se2p6rkb.js} +1 -1
- package/dist/{cli-5v4rw6yd.js → cli-sy7jk3tx.js} +1 -1
- package/dist/{cli-t55yw3a1.js → cli-t8d349cy.js} +2 -2
- package/dist/{cli-0rcyq6d9.js → cli-w2wxd56v.js} +2 -2
- package/dist/{cli-qmdnhpej.js → cli-wz9115x0.js} +17 -7
- package/dist/{cli-5ze9rv97.js → cli-ycrnynpw.js} +4 -4
- package/dist/{cli-a40rnf71.js → cli-ygrbmjja.js} +5 -5
- package/dist/cli.js +12 -12
- package/dist/{codex-adapter-d24q75wk.js → codex-adapter-htjkn22b.js} +2 -2
- package/dist/{codex-session-runner-g50vcsex.js → codex-session-runner-wjfc7smz.js} +2 -2
- package/dist/{commands-c3nrc5hg.js → commands-xhrfwqsk.js} +2 -2
- package/dist/{db-nt7fg3tw.js → db-anvwb22f.js} +2 -2
- package/dist/{handlers-fkr6swat.js → handlers-b9kbc621.js} +8 -8
- package/dist/{hook-g019yehy.js → hook-cgk4wxmx.js} +3 -3
- package/dist/{hook-77wpvnjc.js → hook-xkvn6c5h.js} +2 -2
- package/dist/{http-jewj0syk.js → http-9xh6ta4r.js} +156 -37
- package/dist/{index-hfvggd0z.js → index-25wjsdv2.js} +9 -9
- package/dist/{index-7n2xpbd4.js → index-eaxyqcmj.js} +8 -8
- package/dist/{index-hp1kdbjj.js → index-vg4snay4.js} +10 -10
- package/dist/{index-6gm5he8h.js → index-ywds4812.js} +8 -8
- package/dist/{keepalive-j0fjvqfd.js → keepalive-vc7s2aa6.js} +4 -4
- package/dist/{lead-1n8x30xe.js → lead-r2yqfmx7.js} +24 -24
- package/dist/{maintenance-gvev11sa.js → maintenance-mw70k468.js} +4 -4
- package/dist/{oauth-refresh-sweep-s9ecsvh0.js → oauth-refresh-sweep-yecgjpk4.js} +3 -3
- package/dist/{onboard-pvm5b38w.js → onboard-xj0f73f9.js} +2 -2
- package/dist/{otel-impl-p6jvgd9c.js → otel-impl-nxg0n0b3.js} +1 -1
- package/dist/{pi-mono-adapter-t091s27g.js → pi-mono-adapter-4g5kby31.js} +1 -1
- package/dist/{pricing-refresh-k49rcyed.js → pricing-refresh-2cn6effr.js} +4 -4
- package/dist/{rbac-roles-cr80g96t.js → rbac-roles-1hcxdtg7.js} +2 -2
- package/dist/{rbac-roles-30pspvy7.js → rbac-roles-vynday5g.js} +3 -3
- package/dist/{render-v2-xm457ng1.js → render-v2-tc0bh8kk.js} +3 -3
- package/dist/{seed-pricing-yhpv35fr.js → seed-pricing-8kceb43f.js} +3 -3
- package/dist/{setup-nytnmwwr.js → setup-46xtgq3c.js} +2 -2
- package/dist/{worker-msytr2cw.js → worker-qrbz6hmv.js} +24 -24
- package/openapi.json +1 -1
- package/package.json +2 -2
- package/src/be/migrations/130_scratch_script_retention_grace.sql +6 -0
- package/src/be/scripts/db.ts +43 -1
- package/src/be/scripts/retention.ts +164 -0
- package/src/be/scripts/run-saved.ts +16 -1
- package/src/http/index.ts +5 -0
- package/src/http/scripts.ts +21 -3
- package/src/http/x.ts +14 -0
- package/src/providers/claude-managed-models.ts +34 -10
- package/src/tests/claude-managed-adapter.test.ts +38 -7
- package/src/tests/sandboxed-process.test.ts +45 -0
- package/src/tests/script-connections.test.ts +3 -2
- 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/workflow-swarm-script.test.ts +14 -5
- package/src/utils/sandboxed-process.ts +40 -14
- package/src/workflows/executors/swarm-script.ts +24 -1
- package/templates/schedules/daily-compounding-reflection/content.md +12 -3
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createApp, deleteApp } from "../apps/store";
|
|
3
|
+
import { closeDb, createWorkflow, deleteWorkflow, getDb, initDb } from "../be/db";
|
|
4
|
+
import { runMigrations } from "../be/migrations/runner";
|
|
5
|
+
import { createScriptApi, getScript, insertScript, upsertScriptByName } from "../be/scripts/db";
|
|
6
|
+
import { purgeExpiredScratchScripts } from "../be/scripts/retention";
|
|
7
|
+
import { runSavedScriptAsAgent } from "../be/scripts/run-saved";
|
|
8
|
+
|
|
9
|
+
const TEST_DB_PATH = "./test-scripts-retention.sqlite";
|
|
10
|
+
const NOW = new Date("2026-08-13T12:00:00.000Z");
|
|
11
|
+
const signatureJson = JSON.stringify({ args: null, result: null });
|
|
12
|
+
const savedApiKey = process.env.AGENT_SWARM_API_KEY;
|
|
13
|
+
|
|
14
|
+
async function clearDb(): Promise<void> {
|
|
15
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
16
|
+
try {
|
|
17
|
+
await Bun.file(TEST_DB_PATH + suffix).delete();
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function addScript(name: string, isScratch: boolean) {
|
|
25
|
+
return insertScript({
|
|
26
|
+
name,
|
|
27
|
+
scope: "agent",
|
|
28
|
+
scopeId: "agent-1",
|
|
29
|
+
source: `export default () => "${name}"`,
|
|
30
|
+
description: name,
|
|
31
|
+
intent: name,
|
|
32
|
+
signatureJson,
|
|
33
|
+
isScratch,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("scratch script retention", () => {
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
process.env.AGENT_SWARM_API_KEY = "scripts-retention-test-key";
|
|
40
|
+
await clearDb();
|
|
41
|
+
initDb(TEST_DB_PATH);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
closeDb();
|
|
46
|
+
await clearDb();
|
|
47
|
+
if (savedApiKey === undefined) delete process.env.AGENT_SWARM_API_KEY;
|
|
48
|
+
else process.env.AGENT_SWARM_API_KEY = savedApiKey;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
getDb().run("DELETE FROM scripts");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("purges only scratch-prefixed rows inactive for more than 14 days", () => {
|
|
56
|
+
const staleScratch = addScript("scratch-stale-a1b2c3d4", true);
|
|
57
|
+
const freshScratch = addScript("scratch-fresh-a1b2c3d4", true);
|
|
58
|
+
const namedWithPrefix = addScript("scratch-named-a1b2c3d4", false);
|
|
59
|
+
const flaggedWithoutPrefix = addScript("temporary-script", true);
|
|
60
|
+
const globalScratch = insertScript({
|
|
61
|
+
name: "scratch-global-a1b2c3d4",
|
|
62
|
+
scope: "global",
|
|
63
|
+
source: "export default () => 'global'",
|
|
64
|
+
description: "global",
|
|
65
|
+
intent: "global",
|
|
66
|
+
signatureJson,
|
|
67
|
+
isScratch: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
getDb()
|
|
71
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id IN (?, ?, ?, ?)")
|
|
72
|
+
.run(
|
|
73
|
+
"2026-07-01T00:00:00.000Z",
|
|
74
|
+
staleScratch.id,
|
|
75
|
+
namedWithPrefix.id,
|
|
76
|
+
flaggedWithoutPrefix.id,
|
|
77
|
+
globalScratch.id,
|
|
78
|
+
);
|
|
79
|
+
getDb()
|
|
80
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id = ?")
|
|
81
|
+
.run("2026-08-10T00:00:00.000Z", freshScratch.id);
|
|
82
|
+
|
|
83
|
+
expect(purgeExpiredScratchScripts(NOW)).toBe(1);
|
|
84
|
+
expect(getScript({ name: staleScratch.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
85
|
+
expect(
|
|
86
|
+
getScript({ name: freshScratch.name, scope: "agent", scopeId: "agent-1" }),
|
|
87
|
+
).not.toBeNull();
|
|
88
|
+
expect(
|
|
89
|
+
getScript({ name: namedWithPrefix.name, scope: "agent", scopeId: "agent-1" }),
|
|
90
|
+
).not.toBeNull();
|
|
91
|
+
expect(
|
|
92
|
+
getScript({ name: flaggedWithoutPrefix.name, scope: "agent", scopeId: "agent-1" }),
|
|
93
|
+
).not.toBeNull();
|
|
94
|
+
expect(getScript({ name: globalScratch.name, scope: "global" })).not.toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a stale scratch script referenced by an app definition survives the sweep", () => {
|
|
98
|
+
const wired = addScript("scratch-app-wired-a1b2c3d4", true);
|
|
99
|
+
const unwired = addScript("scratch-app-unwired-a1b2c3d4", true);
|
|
100
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
101
|
+
getDb()
|
|
102
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id IN (?, ?)")
|
|
103
|
+
.run(old, wired.id, unwired.id);
|
|
104
|
+
|
|
105
|
+
const app = createApp({
|
|
106
|
+
name: "retention-test-app",
|
|
107
|
+
definition: {
|
|
108
|
+
models: {},
|
|
109
|
+
actions: { run: { kind: "script", scriptId: wired.id } },
|
|
110
|
+
pages: { main: { root: "root", elements: { root: { type: "Container", props: {} } } } },
|
|
111
|
+
defaultPage: "main",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
expect(purgeExpiredScratchScripts(NOW)).toBe(1);
|
|
117
|
+
expect(getScript({ name: wired.name, scope: "agent", scopeId: "agent-1" })).not.toBeNull();
|
|
118
|
+
expect(getScript({ name: unwired.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
119
|
+
} finally {
|
|
120
|
+
deleteApp(app.id);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a stale scratch script referenced only by a broken app's raw definition string survives the sweep", () => {
|
|
125
|
+
const wired = addScript("scratch-broken-wired-a1b2c3d4", true);
|
|
126
|
+
const unwired = addScript("scratch-broken-unwired-a1b2c3d4", true);
|
|
127
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
128
|
+
getDb()
|
|
129
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id IN (?, ?)")
|
|
130
|
+
.run(old, wired.id, unwired.id);
|
|
131
|
+
|
|
132
|
+
const app = createApp({
|
|
133
|
+
name: "retention-test-broken-app",
|
|
134
|
+
definition: {
|
|
135
|
+
models: {},
|
|
136
|
+
actions: {},
|
|
137
|
+
pages: { main: { root: "root", elements: { root: { type: "Container", props: {} } } } },
|
|
138
|
+
defaultPage: "main",
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
// Corrupt the stored JSON so decodeApp() falls into the invalid-JSON
|
|
142
|
+
// branch: `definition` becomes the raw string itself, and the wired
|
|
143
|
+
// script's id survives only inside that unparseable text.
|
|
144
|
+
getDb()
|
|
145
|
+
.prepare("UPDATE apps SET definition = ? WHERE id = ?")
|
|
146
|
+
.run(`{"actions": {"run": {"scriptId": "${wired.id}"`, app.id);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
expect(purgeExpiredScratchScripts(NOW)).toBe(1);
|
|
150
|
+
expect(getScript({ name: wired.name, scope: "agent", scopeId: "agent-1" })).not.toBeNull();
|
|
151
|
+
expect(getScript({ name: unwired.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
152
|
+
} finally {
|
|
153
|
+
deleteApp(app.id);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a stale scratch script bound to a public API endpoint survives the sweep", () => {
|
|
158
|
+
const wired = addScript("scratch-api-wired-a1b2c3d4", true);
|
|
159
|
+
const unwired = addScript("scratch-api-unwired-a1b2c3d4", true);
|
|
160
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
161
|
+
getDb()
|
|
162
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id IN (?, ?)")
|
|
163
|
+
.run(old, wired.id, unwired.id);
|
|
164
|
+
|
|
165
|
+
createScriptApi({ scriptId: wired.id, agentId: "agent-1", authMode: "none" });
|
|
166
|
+
|
|
167
|
+
expect(purgeExpiredScratchScripts(NOW)).toBe(1);
|
|
168
|
+
expect(getScript({ name: wired.name, scope: "agent", scopeId: "agent-1" })).not.toBeNull();
|
|
169
|
+
expect(getScript({ name: unwired.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a stale scratch script referenced by an agent-scoped workflow swarm-script node survives the sweep", () => {
|
|
173
|
+
const wired = addScript("scratch-wf-wired-a1b2c3d4", true);
|
|
174
|
+
const unwired = addScript("scratch-wf-unwired-a1b2c3d4", true);
|
|
175
|
+
const wrongOwner = addScript("scratch-wf-wrongowner-a1b2c3d4", true);
|
|
176
|
+
const globalNode = addScript("scratch-wf-global-a1b2c3d4", true);
|
|
177
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
178
|
+
getDb()
|
|
179
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id IN (?, ?, ?, ?)")
|
|
180
|
+
.run(old, wired.id, unwired.id, wrongOwner.id, globalNode.id);
|
|
181
|
+
|
|
182
|
+
const workflow = createWorkflow({
|
|
183
|
+
name: "retention-test-workflow",
|
|
184
|
+
definition: {
|
|
185
|
+
nodes: [
|
|
186
|
+
{
|
|
187
|
+
id: "run-it",
|
|
188
|
+
type: "swarm-script",
|
|
189
|
+
config: { scriptName: wired.name, scope: "agent" },
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "run-global",
|
|
193
|
+
type: "swarm-script",
|
|
194
|
+
config: { scriptName: globalNode.name, scope: "global" },
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
onNodeFailure: "fail",
|
|
198
|
+
},
|
|
199
|
+
createdByAgentId: "agent-1",
|
|
200
|
+
});
|
|
201
|
+
// Same script name, wrong workflow owner — must not protect wrongOwner's row.
|
|
202
|
+
const otherOwnerWorkflow = createWorkflow({
|
|
203
|
+
name: "retention-test-workflow-other-owner",
|
|
204
|
+
definition: {
|
|
205
|
+
nodes: [
|
|
206
|
+
{
|
|
207
|
+
id: "run-it",
|
|
208
|
+
type: "swarm-script",
|
|
209
|
+
config: { scriptName: wrongOwner.name, scope: "agent" },
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
onNodeFailure: "fail",
|
|
213
|
+
},
|
|
214
|
+
createdByAgentId: "agent-2",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
expect(purgeExpiredScratchScripts(NOW)).toBe(3);
|
|
219
|
+
expect(getScript({ name: wired.name, scope: "agent", scopeId: "agent-1" })).not.toBeNull();
|
|
220
|
+
expect(getScript({ name: unwired.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
221
|
+
expect(getScript({ name: wrongOwner.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
222
|
+
expect(getScript({ name: globalNode.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
223
|
+
} finally {
|
|
224
|
+
deleteWorkflow(workflow.id);
|
|
225
|
+
deleteWorkflow(otherOwnerWorkflow.id);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a stale scratch script referenced by an ownerless workflow's swarm-script node survives by name alone", () => {
|
|
230
|
+
const wired = addScript("scratch-wf-ownerless-wired-a1b2c3d4", true);
|
|
231
|
+
const unwired = addScript("scratch-wf-ownerless-unwired-a1b2c3d4", true);
|
|
232
|
+
const globalNode = addScript("scratch-wf-ownerless-global-a1b2c3d4", true);
|
|
233
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
234
|
+
getDb()
|
|
235
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id IN (?, ?, ?)")
|
|
236
|
+
.run(old, wired.id, unwired.id, globalNode.id);
|
|
237
|
+
|
|
238
|
+
// No createdByAgentId: the resolving agent is only known at trigger time
|
|
239
|
+
// (SwarmScriptExecutor falls back to trigger.agentId), so the sweep can only
|
|
240
|
+
// match by script name across every agent scope.
|
|
241
|
+
const workflow = createWorkflow({
|
|
242
|
+
name: "retention-test-workflow-ownerless",
|
|
243
|
+
definition: {
|
|
244
|
+
nodes: [
|
|
245
|
+
{
|
|
246
|
+
id: "run-it",
|
|
247
|
+
type: "swarm-script",
|
|
248
|
+
config: { scriptName: wired.name, scope: "agent" },
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "run-global",
|
|
252
|
+
type: "swarm-script",
|
|
253
|
+
config: { scriptName: globalNode.name, scope: "global" },
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
onNodeFailure: "fail",
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
expect(purgeExpiredScratchScripts(NOW)).toBe(2);
|
|
262
|
+
expect(getScript({ name: wired.name, scope: "agent", scopeId: "agent-1" })).not.toBeNull();
|
|
263
|
+
expect(getScript({ name: unwired.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
264
|
+
expect(getScript({ name: globalNode.name, scope: "agent", scopeId: "agent-1" })).toBeNull();
|
|
265
|
+
} finally {
|
|
266
|
+
deleteWorkflow(workflow.id);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a repeated successful scratch auto-save refreshes last-used time without a version bump", async () => {
|
|
271
|
+
const args = {
|
|
272
|
+
name: "scratch-reused-a1b2c3d4",
|
|
273
|
+
scope: "agent" as const,
|
|
274
|
+
scopeId: "agent-1",
|
|
275
|
+
source: "export default () => 1",
|
|
276
|
+
description: "scratch",
|
|
277
|
+
intent: "scratch",
|
|
278
|
+
signatureJson,
|
|
279
|
+
isScratch: true,
|
|
280
|
+
};
|
|
281
|
+
const created = await upsertScriptByName(args);
|
|
282
|
+
getDb()
|
|
283
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id = ?")
|
|
284
|
+
.run("2026-07-01T00:00:00.000Z", created.script.id);
|
|
285
|
+
|
|
286
|
+
const reused = await upsertScriptByName(args);
|
|
287
|
+
|
|
288
|
+
expect(reused.contentDeduped).toBe(true);
|
|
289
|
+
expect(reused.script.version).toBe(1);
|
|
290
|
+
expect(reused.script.updatedAt).not.toBe("2026-07-01T00:00:00.000Z");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("a successful shared saved-script execution refreshes scratch last-used time", async () => {
|
|
294
|
+
const script = addScript("scratch-app-action-a1b2c3d4", true);
|
|
295
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
296
|
+
getDb().prepare("UPDATE scripts SET updatedAt = ? WHERE id = ?").run(old, script.id);
|
|
297
|
+
|
|
298
|
+
const output = await runSavedScriptAsAgent({ script, input: null, agentId: "agent-1" });
|
|
299
|
+
|
|
300
|
+
expect(output.exitCode).toBe(0);
|
|
301
|
+
expect(
|
|
302
|
+
getScript({ name: script.name, scope: "agent", scopeId: "agent-1" })?.updatedAt,
|
|
303
|
+
).not.toBe(old);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("a stale scratch script survives a GC tick that fires while its run is still in flight", async () => {
|
|
307
|
+
const script = addScript("scratch-inflight-a1b2c3d4", true);
|
|
308
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
309
|
+
getDb().prepare("UPDATE scripts SET updatedAt = ? WHERE id = ?").run(old, script.id);
|
|
310
|
+
|
|
311
|
+
// Don't await yet: runSavedScriptAsAgent touches last-used synchronously
|
|
312
|
+
// before its first `await`, so by the time this line returns the row is
|
|
313
|
+
// already fresh — mirroring a run that's still executing when the daily
|
|
314
|
+
// GC tick fires.
|
|
315
|
+
const runPromise = runSavedScriptAsAgent({ script, input: null, agentId: "agent-1" });
|
|
316
|
+
|
|
317
|
+
expect(purgeExpiredScratchScripts(NOW)).toBe(0);
|
|
318
|
+
expect(getScript({ name: script.name, scope: "agent", scopeId: "agent-1" })).not.toBeNull();
|
|
319
|
+
|
|
320
|
+
const output = await runPromise;
|
|
321
|
+
expect(output.exitCode).toBe(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a failed saved-script execution restores the pre-run last-used time instead of extending it", async () => {
|
|
325
|
+
const script = insertScript({
|
|
326
|
+
name: "scratch-failing-a1b2c3d4",
|
|
327
|
+
scope: "agent",
|
|
328
|
+
scopeId: "agent-1",
|
|
329
|
+
source: "export default () => { throw new Error('boom'); }",
|
|
330
|
+
description: "scratch-failing",
|
|
331
|
+
intent: "scratch-failing",
|
|
332
|
+
signatureJson,
|
|
333
|
+
isScratch: true,
|
|
334
|
+
});
|
|
335
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
336
|
+
getDb().prepare("UPDATE scripts SET updatedAt = ? WHERE id = ?").run(old, script.id);
|
|
337
|
+
const staleScript = getScript({
|
|
338
|
+
name: script.name,
|
|
339
|
+
scope: "agent",
|
|
340
|
+
scopeId: "agent-1",
|
|
341
|
+
});
|
|
342
|
+
if (!staleScript) throw new Error("expected script to exist");
|
|
343
|
+
|
|
344
|
+
const output = await runSavedScriptAsAgent({
|
|
345
|
+
script: staleScript,
|
|
346
|
+
input: null,
|
|
347
|
+
agentId: "agent-1",
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(output.exitCode).not.toBe(0);
|
|
351
|
+
expect(getScript({ name: script.name, scope: "agent", scopeId: "agent-1" })?.updatedAt).toBe(
|
|
352
|
+
old,
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("migration grants existing agent scratch rows a fresh retention window only", () => {
|
|
357
|
+
const agentScratch = addScript("scratch-existing-a1b2c3d4", true);
|
|
358
|
+
const namedWithPrefix = addScript("scratch-existing-named-a1b2c3d4", false);
|
|
359
|
+
const globalScratch = insertScript({
|
|
360
|
+
name: "scratch-existing-global-a1b2c3d4",
|
|
361
|
+
scope: "global",
|
|
362
|
+
source: "export default () => 'global'",
|
|
363
|
+
description: "global",
|
|
364
|
+
intent: "global",
|
|
365
|
+
signatureJson,
|
|
366
|
+
isScratch: true,
|
|
367
|
+
});
|
|
368
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
369
|
+
getDb()
|
|
370
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE id IN (?, ?, ?)")
|
|
371
|
+
.run(old, agentScratch.id, namedWithPrefix.id, globalScratch.id);
|
|
372
|
+
getDb().run("DELETE FROM _migrations WHERE version = 130");
|
|
373
|
+
|
|
374
|
+
runMigrations(getDb());
|
|
375
|
+
|
|
376
|
+
expect(
|
|
377
|
+
getScript({ name: agentScratch.name, scope: "agent", scopeId: "agent-1" })?.updatedAt,
|
|
378
|
+
).not.toBe(old);
|
|
379
|
+
expect(
|
|
380
|
+
getScript({ name: namedWithPrefix.name, scope: "agent", scopeId: "agent-1" })?.updatedAt,
|
|
381
|
+
).toBe(old);
|
|
382
|
+
expect(getScript({ name: globalScratch.name, scope: "global" })?.updatedAt).toBe(old);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
initDb,
|
|
12
12
|
} from "../be/db";
|
|
13
13
|
import { upsertScriptConnection } from "../be/script-connections";
|
|
14
|
-
import { upsertScriptByName } from "../be/scripts/db";
|
|
14
|
+
import { getScript, upsertScriptByName } from "../be/scripts/db";
|
|
15
15
|
import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
|
|
16
16
|
import type { Workflow, WorkflowDefinition } from "../types";
|
|
17
17
|
import { startWorkflowExecution } from "../workflows/engine";
|
|
@@ -90,7 +90,7 @@ function makeWorkflow(def: WorkflowDefinition): Workflow {
|
|
|
90
90
|
return wf;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
async function saveScript(name: string, source: string) {
|
|
93
|
+
async function saveScript(name: string, source: string, isScratch = false) {
|
|
94
94
|
return upsertScriptByName({
|
|
95
95
|
name,
|
|
96
96
|
scope: "agent",
|
|
@@ -101,6 +101,7 @@ async function saveScript(name: string, source: string) {
|
|
|
101
101
|
signatureJson,
|
|
102
102
|
agentId,
|
|
103
103
|
typeChecked: true,
|
|
104
|
+
isScratch,
|
|
104
105
|
});
|
|
105
106
|
}
|
|
106
107
|
|
|
@@ -185,14 +186,19 @@ describe("SwarmScriptExecutor", () => {
|
|
|
185
186
|
|
|
186
187
|
test("A workflow with one swarm-script node resolves by name + runs + returns result", async () => {
|
|
187
188
|
await saveScript(
|
|
188
|
-
"add-one",
|
|
189
|
+
"scratch-add-one-a1b2c3d4",
|
|
189
190
|
`export default async (args: { value: number }) => ({ value: args.value + 1 });`,
|
|
191
|
+
true,
|
|
190
192
|
);
|
|
193
|
+
const old = "2026-07-01T00:00:00.000Z";
|
|
194
|
+
getDb()
|
|
195
|
+
.prepare("UPDATE scripts SET updatedAt = ? WHERE name = ? AND scopeId = ?")
|
|
196
|
+
.run(old, "scratch-add-one-a1b2c3d4", agentId);
|
|
191
197
|
|
|
192
198
|
const executor = new SwarmScriptExecutor(deps);
|
|
193
199
|
const wf = makeWorkflow({ nodes: [] });
|
|
194
200
|
const result = await executor.run({
|
|
195
|
-
config: { scriptName: "add-one", args: { value: 6 } },
|
|
201
|
+
config: { scriptName: "scratch-add-one-a1b2c3d4", args: { value: 6 } },
|
|
196
202
|
context: {},
|
|
197
203
|
meta: {
|
|
198
204
|
runId: crypto.randomUUID(),
|
|
@@ -205,7 +211,10 @@ describe("SwarmScriptExecutor", () => {
|
|
|
205
211
|
|
|
206
212
|
expect(result.status).toBe("success");
|
|
207
213
|
expect(result.output?.result).toEqual({ value: 7 });
|
|
208
|
-
expect(result.output?.scriptName).toBe("add-one");
|
|
214
|
+
expect(result.output?.scriptName).toBe("scratch-add-one-a1b2c3d4");
|
|
215
|
+
expect(
|
|
216
|
+
getScript({ name: "scratch-add-one-a1b2c3d4", scope: "agent", scopeId: agentId })?.updatedAt,
|
|
217
|
+
).not.toBe(old);
|
|
209
218
|
});
|
|
210
219
|
|
|
211
220
|
test("named script source with an interpolation token fails with the node and token named", async () => {
|
|
@@ -35,20 +35,36 @@ export const DEFAULT_SANDBOX_LIMITS: SandboxResourceLimits = {
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Bun
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* automatically — callers don't need to special-case it.
|
|
38
|
+
* Bun and Node reserve several GB of virtual address space and start runtime
|
|
39
|
+
* threads before user code runs. Give their process trees enough RLIMIT_AS
|
|
40
|
+
* and RLIMIT_NPROC headroom to boot while retaining the stricter defaults for
|
|
41
|
+
* direct non-interpreter commands.
|
|
43
42
|
*/
|
|
44
43
|
export const BUN_SANDBOX_VIRTUAL_MEMORY_MB = 4096;
|
|
44
|
+
export const JAVASCRIPT_RUNTIME_SANDBOX_MAX_PROCS = 512;
|
|
45
|
+
|
|
46
|
+
const JAVASCRIPT_RUNTIMES = new Set(["bun", "bunx", "node", "nodejs", "npx"]);
|
|
47
|
+
const SHELL_RUNTIMES = new Set(["bash", "dash", "sh"]);
|
|
48
|
+
|
|
49
|
+
function commandBasename(command: string): string {
|
|
50
|
+
return command.slice(command.lastIndexOf("/") + 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function usesInterpreterSandboxProfile(innerCommand: readonly string[]): boolean {
|
|
54
|
+
const runtime = commandBasename(innerCommand[0] ?? "");
|
|
55
|
+
// A shell can invoke another interpreter through arbitrary valid shell
|
|
56
|
+
// grammar (substitutions, functions, variables, heredocs, and more). Treat
|
|
57
|
+
// the shell itself as the runtime boundary instead of letting untrusted
|
|
58
|
+
// source text select its own resource profile through brittle parsing.
|
|
59
|
+
return JAVASCRIPT_RUNTIMES.has(runtime) || SHELL_RUNTIMES.has(runtime);
|
|
60
|
+
}
|
|
45
61
|
|
|
46
62
|
function shellQuote(value: string): string {
|
|
47
63
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
/**
|
|
51
|
-
* Wrap `innerCommand` in a
|
|
67
|
+
* Wrap `innerCommand` in a shell prelude that applies the resource
|
|
52
68
|
* ulimits above, then `exec`s into a completely clean environment (`env -i`)
|
|
53
69
|
* containing ONLY the entries in `env`. This is the enforcement point for
|
|
54
70
|
* two invariants every spawn-user-code path must uphold:
|
|
@@ -60,7 +76,10 @@ function shellQuote(value: string): string {
|
|
|
60
76
|
* token / API key in `env`; pass secrets over stdin instead so they
|
|
61
77
|
* never appear in `/proc/<pid>/environ` or a child's `process.env`.
|
|
62
78
|
*
|
|
63
|
-
*
|
|
79
|
+
* JavaScript-capable runtime trees use Bash intentionally: Ubuntu's `/bin/sh` is
|
|
80
|
+
* dash, whose `ulimit` does not implement `-u` (RLIMIT_NPROC). Other commands
|
|
81
|
+
* retain the existing POSIX-sh sandbox behavior and its strict profile. No-ops
|
|
82
|
+
* (returns `innerCommand` unchanged) on win32, where `ulimit`/`env -i`
|
|
64
83
|
* don't exist — matches the existing native.ts behavior. Because this no-op
|
|
65
84
|
* means `env` is never applied on win32, callers MUST pass `env` itself
|
|
66
85
|
* (not just `{ PATH }`) to `Bun.spawn` on that platform — use
|
|
@@ -74,15 +93,18 @@ export function buildSandboxedCommand(
|
|
|
74
93
|
): string[] {
|
|
75
94
|
if (process.platform === "win32") return [...innerCommand];
|
|
76
95
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
const usesInterpreterProfile = usesInterpreterSandboxProfile(innerCommand);
|
|
97
|
+
const virtualMemoryMb = usesInterpreterProfile
|
|
98
|
+
? Math.max(limits.virtualMemoryMb, BUN_SANDBOX_VIRTUAL_MEMORY_MB)
|
|
99
|
+
: limits.virtualMemoryMb;
|
|
100
|
+
const maxProcs = usesInterpreterProfile
|
|
101
|
+
? Math.max(limits.maxProcs, JAVASCRIPT_RUNTIME_SANDBOX_MAX_PROCS)
|
|
102
|
+
: limits.maxProcs;
|
|
81
103
|
|
|
82
104
|
const ulimits = [
|
|
83
105
|
`ulimit -v ${Math.floor(virtualMemoryMb * 1024)} 2>/dev/null || true`,
|
|
84
106
|
`ulimit -t ${limits.cpuTimeSec} 2>/dev/null || true`,
|
|
85
|
-
`ulimit -u ${
|
|
107
|
+
`ulimit -u ${maxProcs} 2>/dev/null || true`,
|
|
86
108
|
`ulimit -f ${limits.maxFileKb} 2>/dev/null || true`,
|
|
87
109
|
`ulimit -n ${limits.maxFdCount} 2>/dev/null || true`,
|
|
88
110
|
].join("; ");
|
|
@@ -92,7 +114,11 @@ export function buildSandboxedCommand(
|
|
|
92
114
|
.join(" ");
|
|
93
115
|
const quotedInner = innerCommand.map(shellQuote).join(" ");
|
|
94
116
|
|
|
95
|
-
return [
|
|
117
|
+
return [
|
|
118
|
+
usesInterpreterProfile ? "bash" : "sh",
|
|
119
|
+
"-c",
|
|
120
|
+
`${ulimits}; exec env -i ${envAssignments} ${quotedInner}`,
|
|
121
|
+
];
|
|
96
122
|
}
|
|
97
123
|
|
|
98
124
|
/**
|
|
@@ -101,7 +127,7 @@ export function buildSandboxedCommand(
|
|
|
101
127
|
*
|
|
102
128
|
* On POSIX, `buildSandboxedCommand` wraps the command in an `env -i` prelude
|
|
103
129
|
* that injects `env` itself into the child — Bun.spawn only needs PATH to
|
|
104
|
-
* locate the
|
|
130
|
+
* locate the shell binary that runs the prelude.
|
|
105
131
|
*
|
|
106
132
|
* On win32, `buildSandboxedCommand` is a no-op (no `ulimit`/`env -i`), so
|
|
107
133
|
* there is no prelude to inject `env` — it must go through Bun.spawn's own
|
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
getScriptMcpConnectionDescriptors,
|
|
5
5
|
} from "../../be/script-connections";
|
|
6
6
|
import { buildScriptCredentialBindingsWithFailures } from "../../be/script-credential-broker";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getScript,
|
|
9
|
+
getScriptVersion,
|
|
10
|
+
restoreScratchScriptLastUsedIfUnchanged,
|
|
11
|
+
touchScratchScriptLastUsed,
|
|
12
|
+
} from "../../be/scripts/db";
|
|
8
13
|
import {
|
|
9
14
|
DEFAULT_SCRIPT_RESOURCES,
|
|
10
15
|
MAX_SCRIPT_WALL_CLOCK_MS,
|
|
@@ -104,6 +109,12 @@ export class SwarmScriptExecutor extends BaseExecutor<
|
|
|
104
109
|
};
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
// Touch before executing so an already-stale scratch script isn't reaped
|
|
113
|
+
// by the retention sweep while this run is still in flight.
|
|
114
|
+
const runStartTouch = resolved.script.isScratch
|
|
115
|
+
? touchScratchScriptLastUsed(resolved.script.id)
|
|
116
|
+
: null;
|
|
117
|
+
|
|
107
118
|
const credentials = await buildScriptCredentialBindingsWithFailures({
|
|
108
119
|
agentId: agentId ?? undefined,
|
|
109
120
|
});
|
|
@@ -118,6 +129,18 @@ export class SwarmScriptExecutor extends BaseExecutor<
|
|
|
118
129
|
mcpConnections: getScriptMcpConnectionDescriptors({ agentId: agentId ?? undefined }),
|
|
119
130
|
timeoutMs: config.timeoutMs,
|
|
120
131
|
});
|
|
132
|
+
if (output.exitCode === 0 && !output.error && !output.runtimeError) {
|
|
133
|
+
touchScratchScriptLastUsed(resolved.script.id);
|
|
134
|
+
} else if (resolved.script.isScratch && runStartTouch) {
|
|
135
|
+
// Failed run — restore the pre-run timestamp so it doesn't buy the
|
|
136
|
+
// script another retention window, unless a concurrent run already
|
|
137
|
+
// touched it since.
|
|
138
|
+
restoreScratchScriptLastUsedIfUnchanged(
|
|
139
|
+
resolved.script.id,
|
|
140
|
+
resolved.script.updatedAt,
|
|
141
|
+
runStartTouch,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
121
144
|
|
|
122
145
|
const workflowOutput = {
|
|
123
146
|
result: output.result,
|
|
@@ -122,19 +122,28 @@ Skills are the swarm's procedural knowledge — tested playbooks for how to do t
|
|
|
122
122
|
|
|
123
123
|
### 3A. Identify Skill Candidates
|
|
124
124
|
- Review completed tasks: Was any task done by researching something that should have been a skill?
|
|
125
|
-
-
|
|
125
|
+
- Skillify a reusable method the first time it has been validated.
|
|
126
126
|
- Check failed tasks: Did an agent waste context on research that a skill could have prevented?
|
|
127
127
|
- Review agent sessions: Did any agent ignore an existing skill and re-derive the same knowledge? (This is a skill discoverability problem.)
|
|
128
128
|
|
|
129
129
|
### 3B. Create New Skills
|
|
130
130
|
For each candidate:
|
|
131
131
|
1. Draft the skill content (procedure, examples, gotchas)
|
|
132
|
-
2. Use `skill-create` with clear name
|
|
132
|
+
2. Use `skill-create` with full skill content containing a clear name and description
|
|
133
133
|
3. Use `skill-install` to install it for relevant agents
|
|
134
134
|
4. The description and trigger fields are CRITICAL — they determine whether agents find and use the skill
|
|
135
135
|
|
|
136
136
|
### 3C. Update Existing Skills
|
|
137
|
-
-
|
|
137
|
+
- Audit skill bodies inside a one-off `script-run`, using `ctx.swarm.db_query`; do not load the bodies into model context with direct `db-query`, and do not use `skill-list includeContent`.
|
|
138
|
+
- Inside the script, count the enabled skills, then fetch them in explicit pages smaller than the 100-row tool cap (for example, 50 rows at a time):
|
|
139
|
+
```sql
|
|
140
|
+
SELECT COUNT(*) FROM skills WHERE isEnabled = 1;
|
|
141
|
+
SELECT id, name, scope, ownerAgentId, description, content FROM skills
|
|
142
|
+
WHERE isEnabled = 1 ORDER BY name LIMIT 50 OFFSET <offset>;
|
|
143
|
+
```
|
|
144
|
+
- Inspect each page inside the script, continue until the final short page, and return only aggregate counts plus a compact list of findings (skill ID, name, scope/owner, issue, and proposed action) — never the skill bodies. `skill-update` requires the ID, and names are only unique within scope/owner. Require the processed-row count to equal the initial count before treating the audit as complete. Script-internal `ctx.swarm.*` calls bypass the model-context response ceiling and instead fail loudly at the separate 64 MiB guard; pagination also prevents silent row truncation.
|
|
145
|
+
- Before accepting any zero-hit content audit as clean, carry a positive control through the same search path: choose a distinctive token visibly present in one returned skill body and verify that the audit finds it. If the positive control returns zero, treat the audit as invalid rather than clean.
|
|
146
|
+
- Carry a negative control too: search for a freshly invented token that cannot be present and require zero matches. Report both control counts with the processed-row count.
|
|
138
147
|
- Are any skills outdated (e.g., referencing old APIs, wrong procedures)?
|
|
139
148
|
- Are any skills not being used? Check if the description/trigger is too vague
|
|
140
149
|
- Update skills with `skill-update` as needed
|