@desplega.ai/agent-swarm 1.145.0 → 1.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
- package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
- package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
- package/dist/assistant-kt31dj2h.js +236 -0
- package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
- package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
- package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
- package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
- package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
- package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
- package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
- package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
- package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
- package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
- package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
- package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
- package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
- package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
- package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
- package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
- package/dist/cli-9d8ntetj.js +42 -0
- package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
- package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
- package/dist/cli-d8brzxjb.js +190 -0
- package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
- package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
- package/dist/cli-ep19k3mb.js +19 -0
- package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
- package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
- package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
- package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
- package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
- package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
- package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
- package/dist/cli-m9sxbkhm.js +779 -0
- package/dist/cli-mmemxxdc.js +823 -0
- package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
- package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
- package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
- package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
- package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
- package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
- package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
- package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
- package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
- package/dist/cli.js +13 -13
- package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
- package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
- package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
- package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
- package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
- package/dist/handlers-0aspcejm.js +603 -0
- package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
- package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
- package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
- package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
- package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
- package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
- package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
- package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
- package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
- package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
- package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
- package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
- package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
- package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
- package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
- package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
- package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
- package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
- package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
- package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
- package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
- package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
- package/openapi.json +263 -12
- package/package.json +1 -1
- package/plugin/commands/start-worker.md +4 -9
- package/plugin/pi-skills/start-worker/SKILL.md +4 -9
- package/src/be/db/agents.ts +556 -0
- package/src/be/db/context-versions.ts +117 -0
- package/src/be/db/runtime.ts +406 -0
- package/src/be/db/tasks/read.ts +1167 -0
- package/src/be/db/tasks/write.ts +953 -0
- package/src/be/db.ts +584 -3429
- package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
- package/src/be/migrations/148_pages_svg.sql +123 -0
- package/src/be/migrations/149_session_tokens.sql +24 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +13 -8
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
- package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
- package/src/be/seed-scripts/index.ts +17 -0
- package/src/be/seed-skills/index.ts +14 -0
- package/src/be/swarm-config-guard.ts +5 -2
- package/src/be/task-attachment-store.ts +70 -0
- package/src/be/users.ts +77 -0
- package/src/commands/attachments-section.ts +37 -0
- package/src/commands/claude-managed-setup.ts +45 -3
- package/src/commands/claude-md-session.ts +408 -0
- package/src/commands/context-preamble.ts +15 -3
- package/src/commands/profile-sync.ts +205 -5
- package/src/commands/provider-credentials.ts +60 -27
- package/src/commands/runner.ts +63 -58
- package/src/commands/templates.ts +25 -2
- package/src/hooks/hook.ts +26 -67
- package/src/http/agents.ts +40 -1
- package/src/http/auth.ts +13 -1
- package/src/http/fs.ts +21 -51
- package/src/http/index.ts +2 -0
- package/src/http/mcp.ts +32 -0
- package/src/http/pages-public.ts +20 -4
- package/src/http/pages.ts +11 -3
- package/src/http/poll.ts +4 -0
- package/src/http/repos.ts +3 -3
- package/src/http/sessions.ts +82 -0
- package/src/http/tasks.ts +10 -2
- package/src/http/utils.ts +79 -1
- package/src/prompts/session-templates.ts +12 -8
- package/src/providers/acp-adapter.ts +62 -8
- package/src/providers/pi-mono-adapter.ts +23 -6
- package/src/providers/pricing-sources.md +11 -0
- package/src/scheduler/scheduler.test.ts +49 -2
- package/src/scheduler/scheduler.ts +4 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +8 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
- package/src/server.ts +2 -0
- package/src/slack/assistant.ts +87 -46
- package/src/slack/handlers.test.ts +1 -3
- package/src/slack/handlers.ts +102 -87
- package/src/slack/inbound-files.ts +466 -0
- package/src/slack/templates.ts +14 -0
- package/src/tasks/task-terminal-effects.ts +158 -0
- package/src/tests/acp-adapter.test.ts +369 -117
- package/src/tests/budget-claim-gate.test.ts +1 -0
- package/src/tests/build-pi-skills.test.ts +5 -7
- package/src/tests/claude-managed-setup.test.ts +52 -4
- package/src/tests/claude-md-session.test.ts +603 -0
- package/src/tests/codex-skill-resolver.test.ts +19 -0
- package/src/tests/context-versioning.test.ts +107 -0
- package/src/tests/cors-allowlist.test.ts +309 -0
- package/src/tests/credential-check.test.ts +157 -14
- package/src/tests/db-agents.test.ts +91 -0
- package/src/tests/db-context-versions.test.ts +37 -0
- package/src/tests/db-extraction-contract.test.ts +45 -0
- package/src/tests/db-runtime.test.ts +70 -0
- package/src/tests/db-task-read-contract.test.ts +624 -0
- package/src/tests/db-task-write-contract.test.ts +653 -0
- package/src/tests/db-tasks-read.test.ts +183 -0
- package/src/tests/db-tasks-write.test.ts +230 -0
- package/src/tests/defer-task.test.ts +389 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/e2e-visuals-stage.test.ts +151 -0
- package/src/tests/file-lock.test.ts +144 -0
- package/src/tests/fixtures/file-lock-holder.ts +13 -0
- package/src/tests/fixtures/hold-file-lock.ts +25 -0
- package/src/tests/heartbeat-expiry.test.ts +188 -0
- package/src/tests/http-api-integration.test.ts +78 -1
- package/src/tests/pages-public-html.test.ts +43 -0
- package/src/tests/pages-svg-migration.test.ts +42 -0
- package/src/tests/preload.ts +4 -1
- package/src/tests/profile-sync-warning.test.ts +16 -0
- package/src/tests/profile-sync.test.ts +126 -0
- package/src/tests/prompt-template-session.test.ts +20 -4
- package/src/tests/provider-registration.test.ts +93 -0
- package/src/tests/rbac-e2e-helpers.test.ts +53 -0
- package/src/tests/rbac-e2e-helpers.ts +19 -2
- package/src/tests/reload-config.test.ts +26 -0
- package/src/tests/routing-affinity-inventory.test.ts +1 -1
- package/src/tests/runner-attachments-section.test.ts +22 -2
- package/src/tests/runner-context-preamble.test.ts +30 -0
- package/src/tests/runner-deployment-resume.test.ts +107 -0
- package/src/tests/runner-repo-autostash.test.ts +12 -0
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/session-tokens.test.ts +279 -0
- package/src/tests/slack-file-tools-attachments.test.ts +408 -0
- package/src/tests/slack-inbound-files.test.ts +847 -0
- package/src/tests/star-history-refresh.test.ts +102 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
- package/src/tests/swarm-repos.test.ts +20 -0
- package/src/tests/tool-annotations.test.ts +4 -3
- package/src/tests/ui-logs-parser.test.ts +176 -0
- package/src/tools/create-page.ts +4 -2
- package/src/tools/defer-task.ts +239 -0
- package/src/tools/repos/update-repo.ts +2 -2
- package/src/tools/schedules/create-schedule.ts +1 -0
- package/src/tools/schedules/list-schedules.ts +1 -0
- package/src/tools/schedules/patch-schedule.ts +1 -0
- package/src/tools/schedules/update-schedule.ts +1 -0
- package/src/tools/slack-download-file.ts +60 -29
- package/src/tools/slack-read.ts +73 -32
- package/src/tools/store-progress.ts +16 -146
- package/src/tools/tool-config.ts +1 -0
- package/src/tools/utils.ts +21 -0
- package/src/types.ts +37 -4
- package/src/utils/acp-session-token.ts +56 -0
- package/src/utils/file-lock.ts +111 -0
- package/src/utils/heartbeat-expiry.ts +78 -0
- package/src/utils/request-auth-context.ts +2 -0
- package/src/utils/task-attachment-links.ts +22 -0
- package/templates/skills/code-quality/content.md +8 -5
- package/templates/skills/download-task-attachment/content.md +2 -1
- package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
- package/templates/skills/review-offered-task/config.json +14 -0
- package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
- package/templates/skills/scheduling/content.md +4 -0
- package/templates/skills/slack-interaction/content.md +2 -2
- package/templates/skills/work-on-task/SKILL.md +16 -0
- package/templates/skills/work-on-task/config.json +14 -0
- package/templates/skills/work-on-task/content.md +11 -0
- package/dist/assistant-0qt42nzg.js +0 -212
- package/dist/cli-fs1kkqgr.js +0 -317
- package/dist/cli-jnf5zghe.js +0 -269
- package/dist/cli-n1q8q14d.js +0 -177
- package/dist/handlers-yfp1qn81.js +0 -592
- package/plugin/commands/work-on-task.md +0 -39
- package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Holds a `withFileLock` lock from a separate process: prints "locked" once it
|
|
2
|
+
// has it, then keeps it for `holdMs` (or until killed).
|
|
3
|
+
import { withFileLock } from "../../utils/file-lock";
|
|
4
|
+
|
|
5
|
+
const [lockPath, holdMs] = process.argv.slice(2);
|
|
6
|
+
const result = await withFileLock(lockPath as string, async () => {
|
|
7
|
+
console.log("locked");
|
|
8
|
+
await Bun.sleep(Number(holdMs));
|
|
9
|
+
});
|
|
10
|
+
if (!result.acquired) {
|
|
11
|
+
console.log(`not-acquired:${result.reason}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { withFileLock } from "../../utils/file-lock";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hold `lockPath` from this process (a live holder) until the returned release
|
|
5
|
+
* function is awaited. Resolves once the lock is actually held.
|
|
6
|
+
*/
|
|
7
|
+
export async function holdFileLock(lockPath: string): Promise<() => Promise<void>> {
|
|
8
|
+
let release!: () => void;
|
|
9
|
+
const released = new Promise<void>((resolve) => {
|
|
10
|
+
release = resolve;
|
|
11
|
+
});
|
|
12
|
+
let held!: () => void;
|
|
13
|
+
const acquired = new Promise<void>((resolve) => {
|
|
14
|
+
held = resolve;
|
|
15
|
+
});
|
|
16
|
+
const holding = withFileLock(lockPath, async () => {
|
|
17
|
+
held();
|
|
18
|
+
await released;
|
|
19
|
+
});
|
|
20
|
+
await acquired;
|
|
21
|
+
return async () => {
|
|
22
|
+
release();
|
|
23
|
+
await holding;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
getAgentById,
|
|
7
|
+
getDbClient,
|
|
8
|
+
initDb,
|
|
9
|
+
updateAgentProfile,
|
|
10
|
+
} from "../be/db";
|
|
11
|
+
import { handleAgentsRest } from "../http/agents";
|
|
12
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
13
|
+
import { HeartbeatExpiryError, validateHeartbeatExpiry } from "../utils/heartbeat-expiry";
|
|
14
|
+
import { listenOnFreePort } from "./test-net";
|
|
15
|
+
|
|
16
|
+
const section = (...lines: string[]) => `## Tracked Items\n${lines.join("\n")}\n`;
|
|
17
|
+
|
|
18
|
+
describe("heartbeat expiry validation", () => {
|
|
19
|
+
test.each(["DELETE 09-30", "delete on 09-30", "DeLeTe ON 09-30"])("accepts %s", (expiry) => {
|
|
20
|
+
expect(() => validateHeartbeatExpiry("", section(`- New item. ${expiry}`))).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
test("rejects a new undated item and names it", () => {
|
|
23
|
+
expect(() => validateHeartbeatExpiry("", section("- Check the queue"))).toThrow(
|
|
24
|
+
"new tracked item requires DELETE <MM-DD> (or DELETE on <MM-DD>): Check the queue",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
test.each([
|
|
28
|
+
"📌 Permanent rule",
|
|
29
|
+
"- 📌 Permanent rule",
|
|
30
|
+
"- [ ] 📌 Permanent rule",
|
|
31
|
+
])("exempts pin durables: %s", (line) => {
|
|
32
|
+
expect(() => validateHeartbeatExpiry("", section(line))).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
test("does not exempt a pin in the middle of an item", () => {
|
|
35
|
+
expect(() => validateHeartbeatExpiry("", section("- Check 📌 status"))).toThrow(
|
|
36
|
+
HeartbeatExpiryError,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
test("allows unchanged, shortened, and edited legacy items", () => {
|
|
40
|
+
const old = section(
|
|
41
|
+
"- **Queue:** Check the queue every morning and evening",
|
|
42
|
+
"- Keep this item",
|
|
43
|
+
);
|
|
44
|
+
for (const line of [
|
|
45
|
+
"- **Queue:** Check the queue every morning and evening",
|
|
46
|
+
"- **Queue:** Check the queue",
|
|
47
|
+
"- **Queue:** Inspect pending work weekly",
|
|
48
|
+
]) {
|
|
49
|
+
expect(() => validateHeartbeatExpiry(old, section(line, "- Keep this item"))).not.toThrow();
|
|
50
|
+
}
|
|
51
|
+
expect(() =>
|
|
52
|
+
validateHeartbeatExpiry(section("- Check the queue daily"), section("- Check the queue")),
|
|
53
|
+
).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
test("allows pure deletion, including the entire section", () => {
|
|
56
|
+
expect(() => validateHeartbeatExpiry(section("- Old undated item"), section())).not.toThrow();
|
|
57
|
+
expect(() => validateHeartbeatExpiry(section("- Old undated item"), "")).not.toThrow();
|
|
58
|
+
});
|
|
59
|
+
test("uses the last marker and excludes the marker line and later sections", () => {
|
|
60
|
+
const next =
|
|
61
|
+
"## Tracked Items\n- Earlier example\n## Tracked Items (current)\n- New. DELETE 09-30\n## Notes\nUndated notes";
|
|
62
|
+
expect(() => validateHeartbeatExpiry("", next)).not.toThrow();
|
|
63
|
+
expect(() => validateHeartbeatExpiry("", "Mention ## Tracked Items here\n- Undated")).toThrow();
|
|
64
|
+
expect(() => validateHeartbeatExpiry("", "## Tracked Items")).not.toThrow();
|
|
65
|
+
expect(() => validateHeartbeatExpiry("", "## Other\n- Undated")).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
test("does not use a token on the marker or another line", () => {
|
|
68
|
+
expect(() => validateHeartbeatExpiry("", "## Tracked Items DELETE 09-30\n- Undated")).toThrow();
|
|
69
|
+
expect(() =>
|
|
70
|
+
validateHeartbeatExpiry("", section("- Dated DELETE 09-30", "- Undated")),
|
|
71
|
+
).toThrow();
|
|
72
|
+
});
|
|
73
|
+
test("legacy matches are one-to-one, even for reordered lines", () => {
|
|
74
|
+
const old = section("- **Old:** Keep", "- **Other:** Keep");
|
|
75
|
+
expect(() =>
|
|
76
|
+
validateHeartbeatExpiry(old, section("- **Other:** Keep", "- **Old:** Updated")),
|
|
77
|
+
).not.toThrow();
|
|
78
|
+
expect(() =>
|
|
79
|
+
validateHeartbeatExpiry(old, section("- **Old:** Keep", "- **Old:** New")),
|
|
80
|
+
).toThrow();
|
|
81
|
+
expect(() => validateHeartbeatExpiry(old, section("- **Unrelated:** New"))).toThrow();
|
|
82
|
+
});
|
|
83
|
+
test("condensing the middle of a legacy line passes", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
validateHeartbeatExpiry(
|
|
86
|
+
section("- Check the large queue daily"),
|
|
87
|
+
section("- Check the queue"),
|
|
88
|
+
),
|
|
89
|
+
).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
test("dating a legacy item cannot authorize a second undated copy", () => {
|
|
92
|
+
for (const lines of [
|
|
93
|
+
["- **Old:** New", "- **Old:** Updated DELETE 09-30"],
|
|
94
|
+
["- **Old:** Updated DELETE 09-30", "- **Old:** New"],
|
|
95
|
+
["- Legacy text DELETE 09-30", "- Legacy"],
|
|
96
|
+
]) {
|
|
97
|
+
expect(() =>
|
|
98
|
+
validateHeartbeatExpiry(section("- **Old:** Original", "- Legacy text"), section(...lines)),
|
|
99
|
+
).toThrow();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test("does not grandfather removal of a date or pin", () => {
|
|
103
|
+
expect(() =>
|
|
104
|
+
validateHeartbeatExpiry(section("- **Old:** DELETE 09-30"), section("- **Old:** Undated")),
|
|
105
|
+
).toThrow();
|
|
106
|
+
expect(() => validateHeartbeatExpiry(section("📌 Permanent"), section("Permanent"))).toThrow();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const dbPath = "./test-heartbeat-expiry.sqlite";
|
|
111
|
+
describe("profile persistence heartbeat gate", () => {
|
|
112
|
+
const agentId = crypto.randomUUID();
|
|
113
|
+
beforeAll(async () => {
|
|
114
|
+
initDb(dbPath);
|
|
115
|
+
await createAgent({ id: agentId, name: "Expiry test", isLead: false, status: "idle" });
|
|
116
|
+
// Seed pre-gate legacy state directly in this isolated test database.
|
|
117
|
+
await getDbClient().run("UPDATE agents SET heartbeatMd = ? WHERE id = ?", [
|
|
118
|
+
section("- **Legacy:** Old text"),
|
|
119
|
+
agentId,
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
afterAll(async () => {
|
|
123
|
+
closeDb();
|
|
124
|
+
for (const suffix of ["", "-wal", "-shm"])
|
|
125
|
+
await Bun.file(dbPath + suffix)
|
|
126
|
+
.delete()
|
|
127
|
+
.catch(() => {});
|
|
128
|
+
});
|
|
129
|
+
test("rejects atomically before profile or version writes", async () => {
|
|
130
|
+
const before = await getAgentById(agentId);
|
|
131
|
+
const versionsBefore = await getDbClient().query(
|
|
132
|
+
"SELECT * FROM context_versions WHERE agentId = ?",
|
|
133
|
+
[agentId],
|
|
134
|
+
);
|
|
135
|
+
await expect(
|
|
136
|
+
updateAgentProfile(agentId, {
|
|
137
|
+
description: "Must not land",
|
|
138
|
+
heartbeatMd: section("- **Legacy:** Old text", "- New undated item"),
|
|
139
|
+
}),
|
|
140
|
+
).rejects.toThrow(HeartbeatExpiryError);
|
|
141
|
+
expect(await getAgentById(agentId)).toEqual(before);
|
|
142
|
+
expect(
|
|
143
|
+
await getDbClient().query("SELECT * FROM context_versions WHERE agentId = ?", [agentId]),
|
|
144
|
+
).toEqual(versionsBefore);
|
|
145
|
+
});
|
|
146
|
+
test("HTTP returns 400 with the offending line", async () => {
|
|
147
|
+
const server = createServer(async (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
await handleAgentsRest(
|
|
150
|
+
req,
|
|
151
|
+
res,
|
|
152
|
+
getPathSegments(req.url ?? "/"),
|
|
153
|
+
parseQueryParams(req.url ?? "/"),
|
|
154
|
+
agentId,
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
res.writeHead(500).end();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
const port = await listenOnFreePort(server);
|
|
161
|
+
try {
|
|
162
|
+
const response = await fetch(`http://localhost:${port}/api/agents/${agentId}/profile`, {
|
|
163
|
+
method: "PUT",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({ heartbeatMd: section("- New undated HTTP item") }),
|
|
166
|
+
});
|
|
167
|
+
expect(response.status).toBe(400);
|
|
168
|
+
expect((await response.json()).error).toContain("New undated HTTP item");
|
|
169
|
+
} finally {
|
|
170
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
test("persists edits, dated additions, pin durables, and deletions", async () => {
|
|
174
|
+
const next = section("- **Legacy:** Revised", "- Dated DELETE on 09-30", "📌 Durable");
|
|
175
|
+
expect((await updateAgentProfile(agentId, { heartbeatMd: next }))?.heartbeatMd).toBe(next);
|
|
176
|
+
expect((await updateAgentProfile(agentId, { heartbeatMd: "" }))?.heartbeatMd).toBe("");
|
|
177
|
+
});
|
|
178
|
+
test("drops a stale heartbeat before validating, preserving other updates", async () => {
|
|
179
|
+
const result = await updateAgentProfile(
|
|
180
|
+
agentId,
|
|
181
|
+
{ heartbeatMd: section("- Invalid stale item"), description: "Fresh metadata" },
|
|
182
|
+
undefined,
|
|
183
|
+
{ expectedHashes: { heartbeatMd: "stale" } },
|
|
184
|
+
);
|
|
185
|
+
expect(result?.heartbeatMd).toBe("");
|
|
186
|
+
expect(result?.description).toBe("Fresh metadata");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and tests every API endpoint for correct behavior.
|
|
6
6
|
*/
|
|
7
7
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { rm, unlink } from "node:fs/promises";
|
|
10
10
|
import type { Subprocess } from "bun";
|
|
11
11
|
import { Webhook } from "svix";
|
|
@@ -388,6 +388,83 @@ describe("Agents", () => {
|
|
|
388
388
|
expect(unrelated.body.events).toHaveLength(0);
|
|
389
389
|
});
|
|
390
390
|
|
|
391
|
+
test("PUT /api/agents/:id/profile — expectedHashes drops a stale field, keeps the rest", async () => {
|
|
392
|
+
const sha = (v: string) => createHash("sha256").update(v).digest("hex");
|
|
393
|
+
const base = "# CLAUDE.md\n\nbase the session materialized";
|
|
394
|
+
const seeded = await put(`/api/agents/${ids.workerAgent}/profile`, {
|
|
395
|
+
body: { claudeMd: base, changeSource: "self_edit" },
|
|
396
|
+
agentId: ids.workerAgent,
|
|
397
|
+
});
|
|
398
|
+
expect(seeded.status).toBe(200);
|
|
399
|
+
|
|
400
|
+
const reconciledClaudeMd = async () =>
|
|
401
|
+
(
|
|
402
|
+
await get(
|
|
403
|
+
`/api/events?${new URLSearchParams({
|
|
404
|
+
event: "system.profile_sync_reconciled",
|
|
405
|
+
agentId: ids.workerAgent,
|
|
406
|
+
dataField: "claudeMd",
|
|
407
|
+
limit: "100",
|
|
408
|
+
})}`,
|
|
409
|
+
{ agentId: ids.workerAgent },
|
|
410
|
+
)
|
|
411
|
+
).body.events.length as number;
|
|
412
|
+
const before = await reconciledClaudeMd();
|
|
413
|
+
|
|
414
|
+
// A copy based on something the DB already moved past: dropped, 200, and
|
|
415
|
+
// the other field of the same sync still lands.
|
|
416
|
+
const stale = await put(`/api/agents/${ids.workerAgent}/profile`, {
|
|
417
|
+
body: {
|
|
418
|
+
claudeMd: "stale copy",
|
|
419
|
+
toolsMd: "fresh tools",
|
|
420
|
+
changeSource: "session_sync",
|
|
421
|
+
expectedHashes: { claudeMd: sha("an older materialization") },
|
|
422
|
+
},
|
|
423
|
+
agentId: ids.workerAgent,
|
|
424
|
+
});
|
|
425
|
+
expect(stale.status).toBe(200);
|
|
426
|
+
expect(stale.body.claudeMd).toBe(base);
|
|
427
|
+
expect(stale.body.toolsMd).toBe("fresh tools");
|
|
428
|
+
expect(await reconciledClaudeMd()).toBe(before); // not written → not reconciled
|
|
429
|
+
const conflictEvents = await get(
|
|
430
|
+
`/api/events?${new URLSearchParams({
|
|
431
|
+
event: "system.profile_sync_conflict",
|
|
432
|
+
agentId: ids.workerAgent,
|
|
433
|
+
dataField: "claudeMd",
|
|
434
|
+
limit: "1",
|
|
435
|
+
})}`,
|
|
436
|
+
{ agentId: ids.workerAgent },
|
|
437
|
+
);
|
|
438
|
+
expect(conflictEvents.body.events[0]).toMatchObject({
|
|
439
|
+
event: "system.profile_sync_conflict",
|
|
440
|
+
status: "skipped",
|
|
441
|
+
data: {
|
|
442
|
+
field: "claudeMd",
|
|
443
|
+
expectedHash: sha("an older materialization"),
|
|
444
|
+
currentHash: sha(base),
|
|
445
|
+
changeSource: "session_sync",
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// An edit on top of the current value applies.
|
|
450
|
+
const edited = await put(`/api/agents/${ids.workerAgent}/profile`, {
|
|
451
|
+
body: {
|
|
452
|
+
claudeMd: "edited in session",
|
|
453
|
+
changeSource: "session_sync",
|
|
454
|
+
expectedHashes: { claudeMd: sha(base) },
|
|
455
|
+
},
|
|
456
|
+
agentId: ids.workerAgent,
|
|
457
|
+
});
|
|
458
|
+
expect(edited.status).toBe(200);
|
|
459
|
+
expect(edited.body.claudeMd).toBe("edited in session");
|
|
460
|
+
|
|
461
|
+
const malformed = await put(`/api/agents/${ids.workerAgent}/profile`, {
|
|
462
|
+
body: { claudeMd: "x", expectedHashes: { claudeMd: "not-a-sha256" } },
|
|
463
|
+
agentId: ids.workerAgent,
|
|
464
|
+
});
|
|
465
|
+
expect(malformed.status).toBe(400);
|
|
466
|
+
});
|
|
467
|
+
|
|
391
468
|
test("PUT /api/agents/:id/profile — non-existent returns 404", async () => {
|
|
392
469
|
const { status } = await put(`/api/agents/${randomUUID()}/profile`, {
|
|
393
470
|
body: { role: "ghost" },
|
|
@@ -114,6 +114,49 @@ describe("GET /p/:id — HTML public path", () => {
|
|
|
114
114
|
expect(text).toContain("--swarm-text: #111827");
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
for (const authMode of ["public", "authed", "password"]) {
|
|
118
|
+
test(`SVG bytes and security headers survive ${authMode} access`, async () => {
|
|
119
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>★ & stars</text></svg>\n';
|
|
120
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers,
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
slug: `svg-${authMode}`,
|
|
125
|
+
title: "Stars",
|
|
126
|
+
body: svg,
|
|
127
|
+
contentType: "image/svg+xml",
|
|
128
|
+
authMode,
|
|
129
|
+
...(authMode === "password" ? { password: "test-key" } : {}),
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
expect(post.status).toBe(201);
|
|
133
|
+
const { id } = (await post.json()) as { id: string };
|
|
134
|
+
let cookie = "";
|
|
135
|
+
if (authMode !== "public") {
|
|
136
|
+
expect((await fetch(`${BASE}/p/${id}`)).status).toBe(401);
|
|
137
|
+
if (authMode === "authed") {
|
|
138
|
+
const launch = await fetch(`${BASE}/api/pages/${id}/launch`, { method: "POST", headers });
|
|
139
|
+
expect(launch.status).toBe(204);
|
|
140
|
+
cookie = launch.headers.get("set-cookie")!.split(";")[0]!;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const query = authMode === "password" ? "?key=test-key&print=1" : "?print=1";
|
|
144
|
+
const res = await fetch(`${BASE}/p/${id}${query}`, {
|
|
145
|
+
headers: cookie ? { Cookie: cookie } : {},
|
|
146
|
+
});
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
expect(await res.text()).toBe(svg);
|
|
149
|
+
expect(res.headers.get("content-type")).toBe("image/svg+xml");
|
|
150
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
151
|
+
expect(res.headers.get("content-security-policy")).toBe(
|
|
152
|
+
"default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
153
|
+
);
|
|
154
|
+
expect(res.headers.get("cache-control")).toBe(
|
|
155
|
+
authMode === "public" ? "public, max-age=1800" : "private, no-store",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
117
160
|
test("CSP frame ancestors include deprecated DASHBOARD_URL alias", async () => {
|
|
118
161
|
const prevApp = process.env.APP_URL;
|
|
119
162
|
const prevDashboard = process.env.DASHBOARD_URL;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const migration = (name: string) =>
|
|
6
|
+
readFileSync(new URL(`../be/migrations/${name}`, import.meta.url), "utf8");
|
|
7
|
+
|
|
8
|
+
test("SVG migration preserves existing rows, history, indexes and asset key triggers", () => {
|
|
9
|
+
const db = new Database(":memory:");
|
|
10
|
+
try {
|
|
11
|
+
db.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
|
12
|
+
db.exec(migration("059_pages.sql"));
|
|
13
|
+
db.exec(migration("060_page_versions.sql"));
|
|
14
|
+
db.exec("ALTER TABLE pages ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0");
|
|
15
|
+
db.exec("ALTER TABLE pages ADD COLUMN created_by TEXT REFERENCES users(id)");
|
|
16
|
+
db.exec("ALTER TABLE pages ADD COLUMN updated_by TEXT REFERENCES users(id)");
|
|
17
|
+
db.exec(migration("086_pages_default_authed.sql"));
|
|
18
|
+
db.exec("ALTER TABLE pages ADD COLUMN \"key\" TEXT NOT NULL DEFAULT 'shared/'");
|
|
19
|
+
db.exec("INSERT INTO users VALUES ('user')");
|
|
20
|
+
db.exec(`INSERT INTO pages (id,agentId,slug,title,contentType,body,authMode,passwordHash,view_count,created_by,updated_by,"key")
|
|
21
|
+
VALUES ('page','agent','stars','Stars','text/html','original','password','hash',42,'user','user','personal/user/stars/')`);
|
|
22
|
+
db.exec("INSERT INTO page_versions (pageId,version,snapshot) VALUES ('page',1,'{}')");
|
|
23
|
+
const before = db.query("SELECT * FROM pages").get();
|
|
24
|
+
db.transaction(() => db.exec(migration("148_pages_svg.sql")))();
|
|
25
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
26
|
+
expect(db.query("SELECT * FROM pages").get()).toEqual(before);
|
|
27
|
+
expect(db.query("SELECT * FROM page_versions").all()).toHaveLength(1);
|
|
28
|
+
expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
|
|
29
|
+
db.exec("UPDATE pages SET contentType='image/svg+xml'");
|
|
30
|
+
expect(() => db.exec("UPDATE pages SET contentType='text/plain'")).toThrow();
|
|
31
|
+
expect(() => db.exec("UPDATE pages SET \"key\"='INVALID'")).toThrow(
|
|
32
|
+
"invalid asset namespace key",
|
|
33
|
+
);
|
|
34
|
+
expect(
|
|
35
|
+
db.query("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='pages'").all(),
|
|
36
|
+
).toHaveLength(6);
|
|
37
|
+
db.exec("DELETE FROM pages");
|
|
38
|
+
expect(db.query("SELECT * FROM page_versions").all()).toHaveLength(0);
|
|
39
|
+
} finally {
|
|
40
|
+
db.close();
|
|
41
|
+
}
|
|
42
|
+
});
|
package/src/tests/preload.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { closeDb, getDb, initDb } from "../be/db";
|
|
17
|
+
// Populate the registry before hashing the template cache; runtime boot loads this lazily.
|
|
18
|
+
import "../be/seed-prompt-templates";
|
|
17
19
|
import { getAllTemplateDefinitions } from "../prompts/registry";
|
|
18
20
|
import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
|
|
19
21
|
|
|
@@ -102,6 +104,7 @@ function migrationTemplateCacheKey(): string {
|
|
|
102
104
|
const inputs = [
|
|
103
105
|
join(import.meta.dir, "preload.ts"),
|
|
104
106
|
join(import.meta.dir, "../be/db.ts"),
|
|
107
|
+
join(import.meta.dir, "../be/db/runtime.ts"),
|
|
105
108
|
join(import.meta.dir, "../be/seed-prompt-templates.ts"),
|
|
106
109
|
];
|
|
107
110
|
const migrationsDir = join(import.meta.dir, "../be/migrations");
|
|
@@ -114,7 +117,7 @@ function migrationTemplateCacheKey(): string {
|
|
|
114
117
|
hash.update("\n");
|
|
115
118
|
}
|
|
116
119
|
// seedDefaultTemplates bakes the prompt-template registry into the DB; the
|
|
117
|
-
// registry is populated by
|
|
120
|
+
// registry is populated by the explicit seed-prompt-templates import above,
|
|
118
121
|
// so its current content is hashed directly instead of guessing source files.
|
|
119
122
|
hash.update(JSON.stringify(getAllTemplateDefinitions()));
|
|
120
123
|
return hash.digest("hex").slice(0, 32);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { prependContextPreamble } from "../commands/context-preamble";
|
|
2
3
|
import {
|
|
3
4
|
contentSha256,
|
|
4
5
|
fetchProfileSyncRejectionBanner,
|
|
@@ -86,6 +87,21 @@ describe("profile sync rejection session warning", () => {
|
|
|
86
87
|
expect(result.injected).toBeTrue();
|
|
87
88
|
expect(result.prompt).toContain("PERSISTED PROFILE SYNC REJECTION");
|
|
88
89
|
expect(result.prompt).toEndWith("ORIGINAL TASK PROMPT");
|
|
90
|
+
|
|
91
|
+
const context = "\n## Prior Conversation Context\nParent evidence\n\n";
|
|
92
|
+
const taskPrompt = "/work-on-task child-id\n\nTask: follow up";
|
|
93
|
+
const followUp = await prependProfileSyncRejectionBanner(
|
|
94
|
+
prependContextPreamble(taskPrompt, context),
|
|
95
|
+
{ apiUrl: "https://api.example.test", apiKey: "secret-key", agentId: "agent-1" },
|
|
96
|
+
fetchImpl,
|
|
97
|
+
);
|
|
98
|
+
expect(followUp.prompt).toStartWith("/work-on-task child-id\n");
|
|
99
|
+
expect(followUp.prompt).toContain(context);
|
|
100
|
+
expect(followUp.prompt).toContain("PERSISTED PROFILE SYNC REJECTION");
|
|
101
|
+
expect(followUp.prompt).toEndWith("Task: follow up");
|
|
102
|
+
expect(followUp.prompt.indexOf("PERSISTED PROFILE SYNC REJECTION")).toBeLessThan(
|
|
103
|
+
followUp.prompt.indexOf("## Prior Conversation Context"),
|
|
104
|
+
);
|
|
89
105
|
});
|
|
90
106
|
|
|
91
107
|
test("stops warning after the stored field changes", async () => {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import {
|
|
3
6
|
buildIdentityPayload,
|
|
4
7
|
buildIndependentIdentityPayloads,
|
|
8
|
+
CLAUDE_MD_LINEAGE_PATH,
|
|
5
9
|
CLAUDE_MD_PATH,
|
|
10
|
+
claudeMdPendingRecord,
|
|
6
11
|
collectProfilePayloads,
|
|
7
12
|
contentSha256,
|
|
8
13
|
extractSetupScriptContent,
|
|
@@ -23,6 +28,7 @@ import {
|
|
|
23
28
|
import { profileSyncAuditExitCode, runProfileSyncAudit } from "../commands/profile-sync-audit";
|
|
24
29
|
import { MAX_PROFILE_FILE_LENGTH } from "../utils/constants";
|
|
25
30
|
import { IDENTITY_FIELD_BUDGETS } from "../utils/identity-field-budget";
|
|
31
|
+
import { holdFileLock } from "./fixtures/hold-file-lock";
|
|
26
32
|
|
|
27
33
|
const MARKER_START = "# === Agent-managed setup (from DB) ===";
|
|
28
34
|
const MARKER_END = "# === End agent-managed setup ===";
|
|
@@ -478,6 +484,35 @@ describe("postProfileUpdate (non-2xx is surfaced, not swallowed)", () => {
|
|
|
478
484
|
});
|
|
479
485
|
|
|
480
486
|
describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
|
|
487
|
+
test("skips the personal CLAUDE.md, reading nothing, while its lock is held", async () => {
|
|
488
|
+
const dir = await mkdtemp(join(tmpdir(), "claude-md-lock-"));
|
|
489
|
+
const lockPath = join(dir, "CLAUDE.md.lock");
|
|
490
|
+
const release = await holdFileLock(lockPath);
|
|
491
|
+
const sent: string[] = [];
|
|
492
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
493
|
+
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
|
494
|
+
sent.push(String(init?.body));
|
|
495
|
+
return new Response("{}", { status: 200 });
|
|
496
|
+
}) as typeof fetch;
|
|
497
|
+
try {
|
|
498
|
+
await syncProfileFilesToServer({
|
|
499
|
+
agentId: "agent-1",
|
|
500
|
+
apiUrl: "https://api.example.test",
|
|
501
|
+
apiKey: "secret-key",
|
|
502
|
+
changeSource: "session_sync",
|
|
503
|
+
fields: ["claude"],
|
|
504
|
+
claudeMdLock: { path: lockPath, waitMs: 100 },
|
|
505
|
+
fetchImpl,
|
|
506
|
+
});
|
|
507
|
+
expect(sent).toEqual([]);
|
|
508
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("no CLAUDE.md lock (busy)"));
|
|
509
|
+
} finally {
|
|
510
|
+
await release();
|
|
511
|
+
warnSpy.mockRestore();
|
|
512
|
+
await rm(dir, { recursive: true, force: true });
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
481
516
|
test("resolves without throwing even when every POST fails", async () => {
|
|
482
517
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
483
518
|
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
@@ -490,6 +525,7 @@ describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
|
|
|
490
525
|
apiKey: "secret-key",
|
|
491
526
|
changeSource: "session_sync",
|
|
492
527
|
// No files on a CI box → typically no payloads; still must never throw.
|
|
528
|
+
claudeMdLock: { path: join(tmpdir(), `claude-md-${crypto.randomUUID()}.lock`) },
|
|
493
529
|
fetchImpl,
|
|
494
530
|
}),
|
|
495
531
|
).resolves.toBeUndefined();
|
|
@@ -646,6 +682,96 @@ describe("collectProfilePayloads (baseline integration)", () => {
|
|
|
646
682
|
const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
647
683
|
expect(payloads).toHaveLength(1);
|
|
648
684
|
expect(payloads[0]?.body.claudeMd).toBe("modified claude md");
|
|
685
|
+
// The personal file is hook-owned and can hold any session's copy: the
|
|
686
|
+
// runner's backstop sends its boot baseline as the compare-and-set token.
|
|
687
|
+
expect(payloads[0]?.body.expectedHashes).toEqual({ claudeMd: contentSha256("original") });
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("the runner backstop skips content the Claude hook itself wrote", async () => {
|
|
691
|
+
// After a Stop the hook has restored the `.bak`: the file holds a copy a
|
|
692
|
+
// hook wrote (this session's or a sibling's), not an edit.
|
|
693
|
+
const files = reader({
|
|
694
|
+
[CLAUDE_MD_PATH]: "restored by a hook",
|
|
695
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify({ claudeMd: contentSha256("boot") }),
|
|
696
|
+
[CLAUDE_MD_LINEAGE_PATH]: JSON.stringify({
|
|
697
|
+
written: contentSha256("restored by a hook"),
|
|
698
|
+
base: contentSha256("restored by a hook"),
|
|
699
|
+
}),
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
expect(await collectProfilePayloads(["claude"], "session_sync", files)).toEqual([]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("the runner backstop does not push when the lineage record is unreadable", async () => {
|
|
706
|
+
const files = reader({
|
|
707
|
+
[CLAUDE_MD_PATH]: "edited",
|
|
708
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify({ claudeMd: contentSha256("boot") }),
|
|
709
|
+
[CLAUDE_MD_LINEAGE_PATH]: "{not json",
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
expect(await collectProfilePayloads(["claude"], "session_sync", files)).toEqual([]);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("the runner backstop does not push when the record holds a non-sha256 value", async () => {
|
|
716
|
+
const files = reader({
|
|
717
|
+
[CLAUDE_MD_PATH]: "hook materialization",
|
|
718
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify({ claudeMd: contentSha256("boot") }),
|
|
719
|
+
[CLAUDE_MD_LINEAGE_PATH]: JSON.stringify({ written: "not-a-sha256", base: null }),
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
expect(await collectProfilePayloads(["claude"], "session_sync", files)).toEqual([]);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("the runner backstop does not push while a hook write is pending", async () => {
|
|
726
|
+
// A hook write whose lineage never landed leaves the record marked pending:
|
|
727
|
+
// whatever is on disk then counts as hook-written, for the backstop too.
|
|
728
|
+
const files = reader({
|
|
729
|
+
[CLAUDE_MD_PATH]: "edited",
|
|
730
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify({ claudeMd: contentSha256("boot") }),
|
|
731
|
+
[CLAUDE_MD_LINEAGE_PATH]: claudeMdPendingRecord(contentSha256("materialized")),
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
expect(await collectProfilePayloads(["claude"], "session_sync", files)).toEqual([]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("the runner backstop sends an edit against the base the hook recorded", async () => {
|
|
738
|
+
const files = reader({
|
|
739
|
+
[CLAUDE_MD_PATH]: "edited",
|
|
740
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify({ claudeMd: contentSha256("boot") }),
|
|
741
|
+
[CLAUDE_MD_LINEAGE_PATH]: JSON.stringify({
|
|
742
|
+
written: contentSha256("materialized"),
|
|
743
|
+
base: contentSha256("materialized"),
|
|
744
|
+
}),
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
748
|
+
expect(payloads.map((p) => p.body)).toEqual([
|
|
749
|
+
{
|
|
750
|
+
claudeMd: "edited",
|
|
751
|
+
changeSource: "session_sync",
|
|
752
|
+
expectedHashes: { claudeMd: contentSha256("materialized") },
|
|
753
|
+
},
|
|
754
|
+
]);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("session_sync of the workspace CLAUDE.md stays unconditional", async () => {
|
|
758
|
+
// Non-Claude harnesses edit /workspace/CLAUDE.md directly and nothing
|
|
759
|
+
// rematerializes it mid-life, so the boot baseline is not a valid base.
|
|
760
|
+
const baselines: IdentityBaselines = { claudeMd: contentSha256("original") };
|
|
761
|
+
const files = reader({
|
|
762
|
+
[WORKSPACE_CLAUDE_MD_PATH]: "modified workspace md",
|
|
763
|
+
[IDENTITY_BASELINES_PATH]: JSON.stringify(baselines),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const payloads = await collectProfilePayloads(
|
|
767
|
+
["claude"],
|
|
768
|
+
"session_sync",
|
|
769
|
+
files,
|
|
770
|
+
WORKSPACE_CLAUDE_MD_PATH,
|
|
771
|
+
);
|
|
772
|
+
expect(payloads.map((p) => p.body)).toEqual([
|
|
773
|
+
{ claudeMd: "modified workspace md", changeSource: "session_sync" },
|
|
774
|
+
]);
|
|
649
775
|
});
|
|
650
776
|
|
|
651
777
|
test("session_sync proceeds normally when baselines file is missing", async () => {
|
|
@@ -215,11 +215,27 @@ describe("Session templates: MUST pointers", () => {
|
|
|
215
215
|
);
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
test("the
|
|
218
|
+
test("the worker contract names the four task endings", () => {
|
|
219
|
+
const result = resolveTemplate("system.agent.worker", {});
|
|
220
|
+
expect(result.text).toContain("The task has four endings.");
|
|
221
|
+
for (const ending of ["`completed`", "`defer-task`", "`request-human-input`", "`failed`"]) {
|
|
222
|
+
expect(result.text).toContain(ending);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("the lead contract names the delegation tools", () => {
|
|
227
|
+
const result = resolveTemplate("system.agent.lead", {});
|
|
228
|
+
expect(result.text).toContain(
|
|
229
|
+
"Delegate with `send-task`. Read a child's result with `get-task-details`.",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("the lead contract names the desplega skills, not slash commands", () => {
|
|
219
234
|
const result = resolveTemplate("system.agent.lead", {});
|
|
220
|
-
expect(result.text).toContain("
|
|
221
|
-
expect(result.text).toContain("
|
|
222
|
-
expect(result.text).toContain("
|
|
235
|
+
expect(result.text).toContain("`researching` skill");
|
|
236
|
+
expect(result.text).toContain("`planning` skill");
|
|
237
|
+
expect(result.text).toContain("`implementing` skill");
|
|
238
|
+
expect(result.text).not.toContain("/researching");
|
|
223
239
|
});
|
|
224
240
|
});
|
|
225
241
|
|