@desplega.ai/agent-swarm 1.157.0 → 1.158.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/dist/{acp-adapter-qhqrk4q0.js → acp-adapter-xaq4n6pc.js} +1 -1
- package/dist/{actions-9rrg0zxm.js → actions-p9hz5yat.js} +15 -15
- package/dist/{app-enysy1zd.js → app-g26wdcz6.js} +4 -4
- package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
- package/dist/{assistant-zegamy48.js → assistant-wydfqkb3.js} +16 -16
- package/dist/{boot-reembed-r6shx5qx.js → boot-reembed-g5ckg9yb.js} +6 -6
- package/dist/{boot-reembed-zfxb937k.js → boot-reembed-se8zmdcp.js} +5 -5
- package/dist/{boot-scrub-logs-k5y0se6r.js → boot-scrub-logs-wfxqan61.js} +3 -3
- package/dist/{seed-pricing-vqxa06qf.js → boot-seeds-s3q01xj3.js} +10 -10
- package/dist/{claude-adapter-5yc7hxwv.js → claude-adapter-tv2x4f36.js} +7 -7
- package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
- package/dist/{claude-sdk-session-j4ywn3xf.js → claude-sdk-session-t442qz6f.js} +8 -8
- package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
- package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
- package/dist/{cli-yscm6cv8.js → cli-20cj37vt.js} +1866 -9
- package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
- package/dist/{cli-awwphhcs.js → cli-47m6dkd3.js} +362 -126
- package/dist/{cli-wb2zvbfg.js → cli-53sz8gkz.js} +1 -1
- package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
- package/dist/{cli-v53rbqp3.js → cli-65hjmev5.js} +57 -17
- package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
- package/dist/{cli-s98skd5r.js → cli-7gtvvpbh.js} +4 -4
- package/dist/{cli-y3nxwx0t.js → cli-7s3syk22.js} +18 -11
- package/dist/{cli-74h6f2bp.js → cli-8qmme6vd.js} +2 -2
- package/dist/{cli-wsed24mk.js → cli-987hg0aa.js} +2 -2
- package/dist/{cli-9n76jszt.js → cli-9httdpyq.js} +1 -1
- package/dist/{cli-f3va018d.js → cli-aky8tctm.js} +26 -93
- package/dist/{cli-sgyqyyxe.js → cli-c07r9a2m.js} +2 -1
- package/dist/{cli-c41wjx0r.js → cli-fgqxwrpv.js} +291 -12
- package/dist/{cli-01vfmq8c.js → cli-ftfvggzx.js} +607 -164
- package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
- package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
- package/dist/{cli-r9ee9c4w.js → cli-j55n64b6.js} +23 -22
- package/dist/{cli-9p33cz9h.js → cli-j57qkrpq.js} +86 -28
- package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
- package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
- package/dist/{cli-jytrw2na.js → cli-rjehvrts.js} +4 -4
- package/dist/{cli-tpv97m71.js → cli-rqz8qbw7.js} +245 -75
- package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
- package/dist/cli-t1ze64h3.js +811 -0
- package/dist/{cli-6jjzxv3b.js → cli-ws6rqycm.js} +3 -2
- package/dist/{cli-xf5b7y1f.js → cli-wscdvcqz.js} +13 -5
- package/dist/{cli-5j7bm4tb.js → cli-x24pshtq.js} +5 -5
- package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
- package/dist/{cli-vx9p3qdt.js → cli-xhaeg2nc.js} +1 -1
- package/dist/cli.js +29 -15
- package/dist/{codex-adapter-gns3s6c3.js → codex-adapter-cnm8wk11.js} +5 -5
- package/dist/{codex-session-runner-gqnxys5w.js → codex-session-runner-gfg547zj.js} +5 -5
- package/dist/{commands-hxy2y2sp.js → commands-e3bn0p75.js} +3 -3
- package/dist/{db-w8ghhsgf.js → db-03v9kzr2.js} +17 -3
- package/dist/{dispatcher-cp1rknjz.js → dispatcher-nrmg8yht.js} +14 -14
- package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
- package/dist/{handlers-egfhr351.js → handlers-aqcedbyb.js} +17 -17
- package/dist/{hook-x4kftwzt.js → hook-7vkskkre.js} +19 -11
- package/dist/{hook-fbxjqfjz.js → hook-ybvmrmhq.js} +4 -4
- package/dist/{http-c7jfx990.js → http-2eqa8t24.js} +432 -195
- package/dist/{index-4k9hct21.js → index-1yks3qcr.js} +14 -14
- package/dist/{index-jtvpgb8f.js → index-a3vn96n8.js} +30 -27
- package/dist/{index-dc2ek6bw.js → index-c6zsv1bd.js} +5 -5
- package/dist/{index-yy07ef2s.js → index-chgdkq36.js} +14 -14
- package/dist/{index-r6bty6tb.js → index-x71bvq0p.js} +15 -15
- package/dist/{keepalive-f7ekefyc.js → keepalive-vtkf5n99.js} +5 -5
- package/dist/{lead-h053syts.js → lead-h3sp357x.js} +26 -26
- package/dist/{maintenance-a3w6zs54.js → maintenance-jzeqtk3q.js} +6 -6
- package/dist/{oauth-refresh-sweep-vra1dhkd.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
- package/dist/{onboard-mfgzeqxs.js → onboard-tmsp0qbz.js} +278 -81
- package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
- package/dist/{otel-impl-2ez6xk57.js → otel-impl-9mz73q65.js} +416 -301
- package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
- package/dist/{pricing-refresh-6sw65tck.js → pricing-refresh-v1cbr9te.js} +5 -5
- package/dist/{rbac-roles-nm6924sy.js → rbac-roles-c9wqz83x.js} +4 -4
- package/dist/{render-v2-f026j6c4.js → render-v2-gryrh9h8.js} +4 -4
- package/dist/{setup-w606fr1q.js → setup-q9720bt6.js} +2 -2
- package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
- package/dist/{work-objects-ezmggdne.js → work-objects-b76x7deb.js} +17 -17
- package/dist/{worker-1mbyv29k.js → worker-6vrxbv41.js} +26 -26
- package/openapi.json +1626 -629
- package/package.json +2 -1
- package/src/apps/catalog.generated.json +229 -0
- package/src/apps/page-validator.ts +71 -16
- package/src/apps/row-store.ts +66 -11
- package/src/apps/sync.ts +59 -17
- package/src/artifact-sdk/browser-sdk.ts +436 -2
- package/src/be/approval-sweeps.ts +109 -0
- package/src/be/boot-seeds.ts +40 -0
- package/src/be/chunking.ts +1 -1
- package/src/be/db/agents.ts +20 -0
- package/src/be/db/approvals.ts +78 -0
- package/src/be/db/tasks/read.ts +197 -39
- package/src/be/db/tasks/write.ts +41 -9
- package/src/be/db-client.ts +107 -48
- package/src/be/db.ts +219 -76
- package/src/be/events.ts +33 -1
- package/src/be/memory/graph-expansion.ts +10 -3
- package/src/be/memory/providers/sqlite-store.ts +55 -13
- package/src/be/memory/raters/retrieval.ts +5 -2
- package/src/be/memory/reranker.ts +26 -2
- package/src/be/memory/types.ts +2 -0
- package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
- package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
- package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
- package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
- package/src/be/models-catalog.ts +2 -0
- package/src/be/modelsdev-cache.json +1857 -0
- package/src/be/modelsdev-cache.ts +14 -1
- package/src/be/pricing-normalize.ts +2 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +3 -1
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/steering.ts +53 -3
- package/src/be/swarm-config-guard.ts +3 -0
- package/src/be/task-citations.ts +29 -0
- package/src/cli.tsx +14 -1
- package/src/commands/onboard/compose-generator.ts +8 -3
- package/src/commands/onboard/container-engine.ts +220 -0
- package/src/commands/onboard/manifest.ts +1 -0
- package/src/commands/onboard/steps/health-check.tsx +4 -1
- package/src/commands/onboard/steps/prereq-check.tsx +50 -54
- package/src/commands/onboard/steps/start.tsx +31 -16
- package/src/commands/onboard/types.ts +7 -2
- package/src/commands/onboard.tsx +39 -4
- package/src/commands/profile-sync.ts +79 -26
- package/src/commands/runner.ts +8 -1
- package/src/extensions/catalog.generated.json +17 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/heartbeat/heartbeat.ts +263 -103
- package/src/hooks/hook.ts +21 -12
- package/src/http/agents.ts +35 -0
- package/src/http/approval-requests.ts +136 -64
- package/src/http/apps.ts +14 -3
- package/src/http/config.ts +15 -1
- package/src/http/events.ts +62 -2
- package/src/http/index.ts +6 -4
- package/src/http/memory.ts +21 -7
- package/src/http/schedules.ts +1 -1
- package/src/http/session-data.ts +9 -4
- package/src/http/sessions.ts +25 -18
- package/src/http/stats.ts +19 -7
- package/src/http/status.ts +11 -1
- package/src/http/tasks.ts +40 -8
- package/src/http/webhooks.ts +67 -46
- package/src/http/workflows.ts +15 -20
- package/src/linear/outbound.ts +37 -15
- package/src/otel-impl.ts +151 -1
- package/src/otel.ts +14 -3
- package/src/prompts/memories.ts +19 -2
- package/src/providers/claude-managed-models.ts +8 -0
- package/src/providers/modelsdev-reasoning.json +551 -0
- package/src/rbac/legacy-policy.ts +16 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/scheduler/schedule-task.ts +3 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +9 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +12 -7
- package/src/slack/app.ts +35 -4
- package/src/slack/connection-state.ts +17 -0
- package/src/tasks/context-key.ts +1 -0
- package/src/tasks/worker-follow-up.ts +6 -2
- package/src/tests/agent-task-activity.test.ts +74 -0
- package/src/tests/agents-list-model-display.test.ts +6 -1
- package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
- package/src/tests/approval-requests.test.ts +395 -0
- package/src/tests/approval-sweeps-config.test.ts +47 -0
- package/src/tests/approval-sweeps.test.ts +508 -0
- package/src/tests/apps-kibo-blocks.test.ts +77 -0
- package/src/tests/apps-spike.test.ts +56 -1
- package/src/tests/apps-sync-engine.test.ts +20 -9
- package/src/tests/boot-seeds.test.ts +170 -0
- package/src/tests/cancel-approval-request-tool.test.ts +115 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/config-key-filter.test.ts +80 -0
- package/src/tests/context-window.test.ts +1 -0
- package/src/tests/db-client-busy-retry.test.ts +96 -0
- package/src/tests/db-client.test.ts +60 -1
- package/src/tests/db-task-read-contract.test.ts +10 -0
- package/src/tests/db-task-write-contract.test.ts +9 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/events-http.test.ts +103 -0
- package/src/tests/extensions-catalog.test.ts +22 -17
- package/src/tests/extensions-pre-task.test.ts +107 -0
- package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
- package/src/tests/follow-up-config-inheritance.test.ts +260 -0
- package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
- package/src/tests/heartbeat-reboot-live.test.ts +95 -0
- package/src/tests/heartbeat-stall-cas.test.ts +80 -0
- package/src/tests/heartbeat.test.ts +99 -3
- package/src/tests/hook-mcp-config.test.ts +29 -0
- package/src/tests/http-api-integration.test.ts +16 -2
- package/src/tests/kv-storage.test.ts +62 -0
- package/src/tests/linear-outbound-sync.test.ts +120 -5
- package/src/tests/list-endpoint-slimming.test.ts +91 -0
- package/src/tests/memory-graph-organic-route.test.ts +155 -0
- package/src/tests/memory-http-recall-gating.test.ts +14 -4
- package/src/tests/memory-hybrid.test.ts +161 -1
- package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
- package/src/tests/memory.test.ts +22 -0
- package/src/tests/onboard-container-engine.test.ts +240 -0
- package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
- package/src/tests/pricing-routes.test.ts +14 -0
- package/src/tests/profile-sync-warning.test.ts +8 -4
- package/src/tests/promote-draft-task-route.test.ts +19 -0
- package/src/tests/provider-registration.test.ts +296 -42
- package/src/tests/rbac-engine.test.ts +30 -0
- package/src/tests/reasoning-effort.test.ts +8 -0
- package/src/tests/script-connections.test.ts +7 -0
- package/src/tests/sdk-allowlist.test.ts +39 -0
- package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
- package/src/tests/sessions-detail-batch.test.ts +250 -0
- package/src/tests/sessions.test.ts +118 -0
- package/src/tests/slack-config.test.ts +22 -0
- package/src/tests/sonnet-5-5-registry.test.ts +44 -0
- package/src/tests/status.test.ts +36 -1
- package/src/tests/swarm-diff.test.ts +316 -10
- package/src/tests/task-completion-idempotency.test.ts +2 -1
- package/src/tests/tasks-requested-by-filter.test.ts +3 -1
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tests/ui-list-slim-projections.test.ts +158 -0
- package/src/tests/webhooks-task-blocked.test.ts +127 -0
- package/src/tests/workflow-approval-recovery.test.ts +203 -0
- package/src/tests/workflow-hitl-routing.test.ts +22 -1
- package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
- package/src/tests/workflow-http-v2.test.ts +99 -8
- package/src/tests/workflow-retry-validation.test.ts +56 -0
- package/src/tests/workflow-tla-races.test.ts +389 -0
- package/src/tests/workflow-validation-port-routing.test.ts +113 -1
- package/src/tools/cancel-approval-request.ts +57 -0
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/request-human-input.ts +4 -2
- package/src/tools/send-task.ts +4 -0
- package/src/tools/templates.ts +19 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/workflows/list-workflow-runs.ts +1 -0
- package/src/types.ts +38 -0
- package/src/utils/context-window.ts +1 -0
- package/src/workflows/approval-cancel.ts +152 -0
- package/src/workflows/approval-notifications.ts +84 -0
- package/src/workflows/checkpoint.ts +10 -5
- package/src/workflows/definition.ts +21 -0
- package/src/workflows/engine.ts +152 -21
- package/src/workflows/executors/human-in-the-loop.ts +1 -1
- package/src/workflows/recovery.ts +100 -45
- package/src/workflows/resume.ts +49 -18
- package/src/workflows/retry-poller.ts +71 -21
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/github-sender-allowlist/README.md +18 -0
- package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
- package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
- package/templates/skills/apps/content.md +20 -1
- package/templates/skills/code-quality/content.md +26 -1
- package/templates/skills/pages/content.md +16 -4
- package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
- package/dist/cli-2daksqps.js +0 -378
- package/dist/rbac-roles-3gxmnb20.js +0 -37
|
@@ -32,6 +32,15 @@ export type ModelsDevCache = Record<string, ModelsDevProvider>;
|
|
|
32
32
|
|
|
33
33
|
export const MODELSDEV_CACHE_PATH = path.join("src", "be", "modelsdev-cache.json");
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Parsed snapshots by file path, so a changed `MODELSDEV_CACHE_PATH` is honoured.
|
|
37
|
+
* The file is 8.6 MB and its `JSON.parse` blocks the event loop for ~40 ms, so a
|
|
38
|
+
* second load while the first result is still alive reuses it. References are
|
|
39
|
+
* weak: the parsed tree is ~27 MB of heap that nothing needs pinned once the
|
|
40
|
+
* seed and catalog have read it. Callers must treat the result as read-only.
|
|
41
|
+
*/
|
|
42
|
+
const parsedCaches = new Map<string, WeakRef<ModelsDevCache>>();
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* Resolve the vendored models.dev cache from source checkouts and compiled
|
|
37
46
|
* Docker images. The API image copies the snapshot to `/app/src/be/...`.
|
|
@@ -53,8 +62,12 @@ export function loadModelsDevCache(): ModelsDevCache | null {
|
|
|
53
62
|
];
|
|
54
63
|
|
|
55
64
|
for (const candidate of candidates) {
|
|
65
|
+
const memoized = parsedCaches.get(candidate)?.deref();
|
|
66
|
+
if (memoized) return memoized;
|
|
56
67
|
try {
|
|
57
|
-
|
|
68
|
+
const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as ModelsDevCache;
|
|
69
|
+
parsedCaches.set(candidate, new WeakRef(parsed));
|
|
70
|
+
return parsed;
|
|
58
71
|
} catch {
|
|
59
72
|
// try next candidate
|
|
60
73
|
}
|
|
@@ -55,6 +55,8 @@ const ROUTING_PREFIXES_BY_PROVIDER: Record<PricingProvider, readonly string[]> =
|
|
|
55
55
|
// A generic ACP target reports whatever model id it likes and has no seeded
|
|
56
56
|
// rate rows, so there is no canonical key to collapse onto — no-op.
|
|
57
57
|
acp: [],
|
|
58
|
+
// dsh emits bare model ids today — no-op, same as claude/devin/gemini above.
|
|
59
|
+
dsh: [],
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
/**
|
|
@@ -391,6 +391,7 @@ export interface SwarmSdk {
|
|
|
391
391
|
|
|
392
392
|
// --- human input ---
|
|
393
393
|
request_humanInput(args: Record<string, unknown>): Promise<unknown>;
|
|
394
|
+
approval_cancel(args: { requestId: string; reason?: string }): Promise<unknown>;
|
|
394
395
|
}
|
|
395
396
|
|
|
396
397
|
export interface ScriptStdlib {
|
package/src/be/seed-pricing.ts
CHANGED
|
@@ -90,7 +90,7 @@ const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
|
|
|
90
90
|
fable: "claude-fable-5-1",
|
|
91
91
|
mythos: "claude-mythos-5-1",
|
|
92
92
|
opus: "claude-opus-5-5",
|
|
93
|
-
sonnet: "claude-sonnet-5",
|
|
93
|
+
sonnet: "claude-sonnet-5-5",
|
|
94
94
|
haiku: "claude-haiku-4-5",
|
|
95
95
|
};
|
|
96
96
|
|
|
@@ -98,6 +98,8 @@ const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
|
|
|
98
98
|
const ANTHROPIC_SHORTNAME_PRICING_FALLBACKS: Record<string, ModelsDevCostBlock> = {
|
|
99
99
|
// https://platform.claude.com/docs/en/about-claude/pricing (verified 2026-09-22)
|
|
100
100
|
"claude-opus-5-5": { input: 4, output: 20, cache_read: 0.2, cache_write: 5 },
|
|
101
|
+
// https://platform.claude.com/docs/en/about-claude/pricing (verified 2026-09-28)
|
|
102
|
+
"claude-sonnet-5-5": { input: 2, output: 10, cache_read: 0.2, cache_write: 2.5 },
|
|
101
103
|
"claude-fable-5-1": { input: 10, output: 50, cache_read: 0.25, cache_write: 12.5 },
|
|
102
104
|
"claude-mythos-5-1": { input: 10, output: 50, cache_read: 0.25, cache_write: 12.5 },
|
|
103
105
|
};
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
60
|
"path": "examples/multiplayer-board.html",
|
|
61
|
-
"content": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Shared board</title>\n <style>\n :root {\n color: #252522;\n background: #f7f6f3;\n font-family: \"SF Pro Display\", \"Helvetica Neue\", ui-sans-serif, system-ui, sans-serif;\n font-synthesis: none;\n }\n\n * { box-sizing: border-box; }\n\n body {\n margin: 0;\n min-width: 320px;\n padding: 0;\n background: #f7f6f3;\n color: #252522;\n font-family: \"SF Pro Display\", \"Helvetica Neue\", ui-sans-serif, system-ui, sans-serif;\n }\n\n button, input, textarea { font: inherit; }\n\n button {\n border: 1px solid #252522;\n border-radius: 8px;\n background: #252522;\n color: #fff;\n cursor: pointer;\n padding: 10px 14px;\n }\n\n button:hover { background: #3c3c37; }\n button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #e1a52c; outline-offset: 2px; }\n button:disabled { cursor: not-allowed; opacity: .5; }\n\n .page { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }\n\n header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 24px;\n border-bottom: 1px solid #d9d7d0;\n padding-bottom: 24px;\n }\n\n h1, h2, p { margin-top: 0; }\n h1 { font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.04em; margin-bottom: 8px; }\n h2 { font-size: 1.15rem; letter-spacing: -.02em; margin-bottom: 16px; }\n .lede, .muted { color: #67665f; }\n .lede { max-width: 46rem; margin-bottom: 0; }\n\n .viewer {\n min-width: 170px;\n border: 1px solid #d9d7d0;\n border-radius: 8px;\n background: #fff;\n padding: 12px;\n }\n\n .eyebrow {\n color: #67665f;\n display: block;\n font-size: .72rem;\n font-weight: 700;\n letter-spacing: .08em;\n margin-bottom: 4px;\n text-transform: uppercase;\n }\n\n .viewer-name { font-weight: 700; overflow-wrap: anywhere; }\n\n .notice {\n border: 1px solid #d9d7d0;\n border-left: 4px solid #e1a52c;\n border-radius: 8px;\n background: #fffdf8;\n margin: 24px 0;\n min-height: 47px;\n padding: 12px 14px;\n }\n\n .notice[data-tone=\"error\"] { border-left-color: #a34736; background: #fff9f7; }\n .notice[data-tone=\"stale\"] { border-left-color: #e1a52c; background: #fffdf8; }\n .notice:empty { display: none; }\n\n .grid { display: grid; gap: 24px; grid-template-columns: minmax(0, 1.65fr) minmax(260px, .85fr); }\n .panel { border: 1px solid #d9d7d0; border-radius: 12px; background: #fff; padding: 20px; }\n .panel + .panel { margin-top: 24px; }\n\n .add-row { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto; margin-bottom: 16px; }\n input, textarea {\n border: 1px solid #bdbbb4;\n border-radius: 8px;\n background: #fff;\n color: inherit;\n padding: 10px 12px;\n width: 100%;\n }\n\n textarea { min-height: 94px; resize: vertical; }\n label { display: block; font-size: .88rem; font-weight: 650; margin-bottom: 8px; }\n\n .cards, .peers { list-style: none; margin: 0; padding: 0; }\n .cards { border-top: 1px solid #e7e5df; }\n .card {\n align-items: center;\n border-bottom: 1px solid #e7e5df;\n display: flex;\n gap: 12px;\n justify-content: space-between;\n padding: 13px 0;\n }\n\n .card-title { font-weight: 650; overflow-wrap: anywhere; }\n .card-status {\n background: #fff;\n border-color: #bdbbb4;\n color: #252522;\n flex: 0 0 auto;\n font-size: .82rem;\n padding: 7px 10px;\n }\n\n .card-status[data-done=\"true\"] { background: #f6efd9; border-color: #c89429; }\n .empty { color: #67665f; padding: 16px 0 4px; }\n .agent-note { background: #f7f6f3; border-radius: 8px; min-height: 46px; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }\n .agent-note:empty::before { color: #67665f; content: \"No note yet. A server-side agent can write one here.\"; }\n\n .peers { display: grid; gap: 8px; }\n .peer {\n border: 1px solid #e7e5df;\n border-radius: 8px;\n display: grid;\n gap: 3px;\n padding: 10px;\n }\n\n .peer-name { font-weight: 650; overflow-wrap: anywhere; }\n .cursor { color: #67665f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }\n .room-meta { color: #67665f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .75rem; margin: 0 0 16px; }\n .save-state { color: #67665f; font-size: .82rem; margin: 8px 0 0; min-height: 1.2em; }\n\n @media (max-width: 720px) {\n .page { padding: 24px 16px 40px; }\n header { align-items: stretch; flex-direction: column; }\n .viewer { min-width: 0; }\n .grid { grid-template-columns: 1fr; }\n .add-row { grid-template-columns: 1fr; }\n }\n </style>\n </head>\n <body>\n <main class=\"page\">\n <header>\n <div>\n <h1>Shared board</h1>\n <p class=\"lede\">A small board shared by everyone in match-42. Changes appear as peers make them.</p>\n </div>\n <section class=\"viewer\" aria-label=\"Current viewer\">\n <span class=\"eyebrow\">Connected as</span>\n <div class=\"viewer-name\" id=\"viewer-name\">Connecting…</div>\n </section>\n </header>\n\n <div id=\"notice\" class=\"notice\" role=\"status\" aria-live=\"polite\"></div>\n\n <div class=\"grid\">\n <section class=\"panel\" aria-labelledby=\"board-heading\">\n <h2 id=\"board-heading\">Match board</h2>\n <p class=\"room-meta\">Room: match-42 · Schema: 1</p>\n <form id=\"add-card-form\" class=\"add-row\">\n <label for=\"card-title\" class=\"sr-only\" hidden>New card title</label>\n <input id=\"card-title\" name=\"title\" maxlength=\"120\" placeholder=\"Write a card title\" required>\n <button type=\"submit\">Add card</button>\n </form>\n <ul id=\"cards\" class=\"cards\" aria-live=\"polite\"></ul>\n </section>\n\n <aside>\n <section class=\"panel\" aria-labelledby=\"peers-heading\">\n <h2 id=\"peers-heading\">Live peers</h2>\n <p class=\"muted\">Cursor values use each peer's viewport coordinates.</p>\n <ul id=\"peers\" class=\"peers\" aria-live=\"polite\"></ul>\n </section>\n\n <section class=\"panel\" aria-labelledby=\"agent-note-heading\">\n <h2 id=\"agent-note-heading\">Agent note</h2>\n <div id=\"agent-note\" class=\"agent-note\" aria-live=\"polite\"></div>\n </section>\n </aside>\n </div>\n\n <section class=\"panel\" aria-labelledby=\"lobby-heading\">\n <h2 id=\"lobby-heading\">Lobby</h2>\n <p class=\"room-meta\">Room: lobby · Schema: 1</p>\n <form id=\"lobby-form\">\n <label for=\"lobby-message\">Shared lobby message</label>\n <textarea id=\"lobby-message\" maxlength=\"1000\" placeholder=\"Leave a message for everyone in the lobby\"></textarea>\n <button type=\"submit\">Save lobby message</button>\n <p id=\"lobby-save-state\" class=\"save-state\" aria-live=\"polite\"></p>\n </form>\n </section>\n </main>\n\n <script>\n (() => {\n const ui = {\n viewerName: document.querySelector('#viewer-name'),\n notice: document.querySelector('#notice'),\n cards: document.querySelector('#cards'),\n peers: document.querySelector('#peers'),\n agentNote: document.querySelector('#agent-note'),\n addCardForm: document.querySelector('#add-card-form'),\n cardTitle: document.querySelector('#card-title'),\n lobbyForm: document.querySelector('#lobby-form'),\n lobbyMessage: document.querySelector('#lobby-message'),\n lobbySaveState: document.querySelector('#lobby-save-state')\n };\n\n let board;\n let lobby;\n let peers = [];\n let lobbyDirty = false;\n\n function message(error) {\n return error && error.message ? error.message : String(error);\n }\n\n function showNotice(text, tone) {\n ui.notice.textContent = text || '';\n ui.notice.dataset.tone = tone || '';\n }\n\n function markStale(room, roomName) {\n if (!room.stale) return false;\n showNotice(`${roomName} uses a different schema version. Its saved state remains visible, but this page cannot write changes.`, 'stale');\n return true;\n }\n\n function renderBoard(state) {\n const cards = state && state.cards && typeof state.cards === 'object' ? state.cards : {};\n ui.cards.replaceChildren();\n const entries = Object.entries(cards);\n\n if (!entries.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No cards yet. Add the first card.';\n ui.cards.append(empty);\n }\n\n for (const [id, card] of entries) {\n const item = document.createElement('li');\n item.className = 'card';\n const title = document.createElement('span');\n title.className = 'card-title';\n title.textContent = card && typeof card.title === 'string' ? card.title : 'Untitled card';\n const toggle = document.createElement('button');\n const done = Boolean(card && card.done);\n toggle.className = 'card-status';\n toggle.dataset.done = String(done);\n toggle.type = 'button';\n toggle.textContent = done ? 'Done' : 'Open';\n toggle.setAttribute('aria-label', `Mark ${title.textContent} as ${done ? 'open' : 'done'}`);\n toggle.addEventListener('click', () => toggleCard(id));\n item.append(title, toggle);\n ui.cards.append(item);\n }\n\n ui.agentNote.textContent = state && typeof state.agentNote === 'string' ? state.agentNote : '';\n }\n\n function renderPeers(nextPeers) {\n peers = Array.isArray(nextPeers) ? nextPeers : [];\n ui.peers.replaceChildren();\n\n if (!peers.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No other peers are connected.';\n ui.peers.append(empty);\n return;\n }\n\n for (const peer of peers) {\n const item = document.createElement('li');\n item.className = 'peer';\n const name = document.createElement('span');\n name.className = 'peer-name';\n name.textContent = peer.name || 'Guest';\n const cursor = document.createElement('span');\n cursor.className = 'cursor';\n const data = peer.data || {};\n cursor.textContent = Number.isFinite(data.x) && Number.isFinite(data.y)\n ? `cursor: ${Math.round(data.x)}, ${Math.round(data.y)}`\n : 'cursor: not shared yet';\n item.append(name, cursor);\n ui.peers.append(item);\n }\n }\n\n function renderLobby(state) {\n if (lobbyDirty) return;\n ui.lobbyMessage.value = state && typeof state.message === 'string' ? state.message : '';\n }\n\n async function addCard(title) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n state.cards ??= {};\n state.cards[crypto.randomUUID()] = { title, done: false };\n });\n ui.cardTitle.value = '';\n showNotice('Card added.', 'success');\n } catch (error) {\n showNotice(`Could not add the card: ${message(error)}`, 'error');\n }\n }\n\n async function toggleCard(id) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n const card = state.cards && state.cards[id];\n if (card) card.done = !card.done;\n });\n } catch (error) {\n showNotice(`Could not update the card: ${message(error)}`, 'error');\n }\n }\n\n async function saveLobbyMessage() {\n if (markStale(lobby, 'lobby')) return;\n try {\n const value = ui.lobbyMessage.value;\n await lobby.change(state => {\n state.message = value;\n });\n lobbyDirty = false;\n ui.lobbySaveState.textContent = 'Saved to the lobby room.';\n } catch (error) {\n ui.lobbySaveState.textContent = `Could not save: ${message(error)}`;\n showNotice(`Could not save the lobby message: ${message(error)}`, 'error');\n }\n }\n\n async function initialize() {\n try {\n board = await window.swarmSdk.room('match-42', { schemaVersion: 1 });\n lobby = await window.swarmSdk.room('lobby', { schemaVersion: 1 });\n\n const viewer = board.me || lobby.me || {};\n ui.viewerName.textContent = viewer.name || 'Guest';\n\n board.on('change', renderBoard);\n board.on('presence', renderPeers);\n board.on('error', error => showNotice(`match-42 error: ${message(error)}`, 'error'));\n board.on('reset', () => showNotice('match-42 was reset by another participant. The board has resynchronized.', 'stale'));\n lobby.on('change', renderLobby);\n lobby.on('error', error => showNotice(`lobby error: ${message(error)}`, 'error'));\n lobby.on('reset', () => showNotice('lobby was reset by another participant. The lobby has resynchronized.', 'stale'));\n\n renderBoard(board.state);\n renderPeers(board.presence.peers);\n renderLobby(lobby.state);\n markStale(board, 'match-42');\n markStale(lobby, 'lobby');\n\n if (!board.stale) {\n await board.change(state => {\n state.cards ??= {};\n state.agentNote ??= '';\n });\n }\n if (!lobby.stale) {\n await lobby.change(state => {\n state.message ??= '';\n state.activeMatch ??= 'match-42';\n });\n }\n } catch (error) {\n showNotice(`Could not connect to the shared rooms: ${message(error)}`, 'error');\n ui.viewerName.textContent = 'Unavailable';\n }\n }\n\n ui.addCardForm.addEventListener('submit', event => {\n event.preventDefault();\n const title = ui.cardTitle.value.trim();\n if (title) addCard(title);\n });\n ui.lobbyMessage.addEventListener('input', () => { lobbyDirty = true; });\n ui.lobbyForm.addEventListener('submit', event => {\n event.preventDefault();\n saveLobbyMessage();\n });\n document.addEventListener('pointermove', event => {\n if (board) board.presence.set({ x: event.clientX, y: event.clientY });\n }, { passive: true });\n window.addEventListener('pagehide', () => {\n if (board) board.close();\n if (lobby) lobby.close();\n });\n\n initialize();\n })();\n </script>\n </body>\n</html>\n"
|
|
61
|
+
"content": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Shared board</title>\n <style>\n :root {\n color: #252522;\n background: #f7f6f3;\n font-family: \"SF Pro Display\", \"Helvetica Neue\", ui-sans-serif, system-ui, sans-serif;\n font-synthesis: none;\n }\n\n * { box-sizing: border-box; }\n\n body {\n margin: 0;\n min-width: 320px;\n padding: 0;\n background: #f7f6f3;\n color: #252522;\n font-family: \"SF Pro Display\", \"Helvetica Neue\", ui-sans-serif, system-ui, sans-serif;\n }\n\n button, input, textarea { font: inherit; }\n\n button {\n border: 1px solid #252522;\n border-radius: 8px;\n background: #252522;\n color: #fff;\n cursor: pointer;\n padding: 10px 14px;\n }\n\n button:hover { background: #3c3c37; }\n button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #e1a52c; outline-offset: 2px; }\n button:disabled { cursor: not-allowed; opacity: .5; }\n\n .page { max-width: 1120px; margin: 0 auto; padding: 32px 24px 56px; }\n\n header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 24px;\n border-bottom: 1px solid #d9d7d0;\n padding-bottom: 24px;\n }\n\n h1, h2, p { margin-top: 0; }\n h1 { font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -.04em; margin-bottom: 8px; }\n h2 { font-size: 1.15rem; letter-spacing: -.02em; margin-bottom: 16px; }\n .lede, .muted { color: #67665f; }\n .lede { max-width: 46rem; margin-bottom: 0; }\n\n .viewer {\n min-width: 170px;\n border: 1px solid #d9d7d0;\n border-radius: 8px;\n background: #fff;\n padding: 12px;\n }\n\n .eyebrow {\n color: #67665f;\n display: block;\n font-size: .72rem;\n font-weight: 700;\n letter-spacing: .08em;\n margin-bottom: 4px;\n text-transform: uppercase;\n }\n\n .viewer-name { font-weight: 700; overflow-wrap: anywhere; }\n\n .notice {\n border: 1px solid #d9d7d0;\n border-left: 4px solid #e1a52c;\n border-radius: 8px;\n background: #fffdf8;\n margin: 24px 0;\n min-height: 47px;\n padding: 12px 14px;\n }\n\n .notice[data-tone=\"error\"] { border-left-color: #a34736; background: #fff9f7; }\n .notice[data-tone=\"stale\"] { border-left-color: #e1a52c; background: #fffdf8; }\n .notice:empty { display: none; }\n\n .grid { display: grid; gap: 24px; grid-template-columns: minmax(0, 1.65fr) minmax(260px, .85fr); }\n .panel { border: 1px solid #d9d7d0; border-radius: 12px; background: #fff; padding: 20px; }\n .panel + .panel { margin-top: 24px; }\n\n .add-row { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr) auto; margin-bottom: 16px; }\n input, textarea {\n border: 1px solid #bdbbb4;\n border-radius: 8px;\n background: #fff;\n color: inherit;\n padding: 10px 12px;\n width: 100%;\n }\n\n textarea { min-height: 94px; resize: vertical; }\n label { display: block; font-size: .88rem; font-weight: 650; margin-bottom: 8px; }\n\n .cards, .peers { list-style: none; margin: 0; padding: 0; }\n .cards { border-top: 1px solid #e7e5df; }\n .card {\n align-items: center;\n border-bottom: 1px solid #e7e5df;\n display: flex;\n gap: 12px;\n justify-content: space-between;\n padding: 13px 0;\n }\n\n .card-title { font-weight: 650; overflow-wrap: anywhere; }\n .card-status {\n background: #fff;\n border-color: #bdbbb4;\n color: #252522;\n flex: 0 0 auto;\n font-size: .82rem;\n padding: 7px 10px;\n }\n\n .card-status[data-done=\"true\"] { background: #f6efd9; border-color: #c89429; }\n .empty { color: #67665f; padding: 16px 0 4px; }\n .agent-note { background: #f7f6f3; border-radius: 8px; min-height: 46px; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }\n .agent-note:empty::before { color: #67665f; content: \"No note yet. A server-side agent can write one here.\"; }\n\n .peers { display: grid; gap: 8px; }\n .peer {\n border: 1px solid #e7e5df;\n border-radius: 8px;\n display: grid;\n gap: 3px;\n padding: 10px;\n }\n\n .peer-name { font-weight: 650; overflow-wrap: anywhere; }\n .room-meta { color: #67665f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .75rem; margin: 0 0 16px; }\n .save-state { color: #67665f; font-size: .82rem; margin: 8px 0 0; min-height: 1.2em; }\n\n @media (max-width: 720px) {\n .page { padding: 24px 16px 40px; }\n header { align-items: stretch; flex-direction: column; }\n .viewer { min-width: 0; }\n .grid { grid-template-columns: 1fr; }\n .add-row { grid-template-columns: 1fr; }\n }\n </style>\n </head>\n <body>\n <swarm-cursors room=\"match-42\"></swarm-cursors>\n <main class=\"page\">\n <header>\n <div>\n <h1>Shared board</h1>\n <p class=\"lede\">A small board shared by everyone in match-42. Changes appear as peers make them.</p>\n </div>\n <section class=\"viewer\" aria-label=\"Current viewer\">\n <span class=\"eyebrow\">Connected as</span>\n <div class=\"viewer-name\" id=\"viewer-name\">Connecting…</div>\n </section>\n </header>\n\n <div id=\"notice\" class=\"notice\" role=\"status\" aria-live=\"polite\"></div>\n\n <div class=\"grid\">\n <section class=\"panel\" aria-labelledby=\"board-heading\">\n <h2 id=\"board-heading\">Match board</h2>\n <p class=\"room-meta\">Room: match-42 · Schema: 1</p>\n <form id=\"add-card-form\" class=\"add-row\">\n <label for=\"card-title\" class=\"sr-only\" hidden>New card title</label>\n <input id=\"card-title\" name=\"title\" maxlength=\"120\" placeholder=\"Write a card title\" required>\n <button type=\"submit\">Add card</button>\n </form>\n <ul id=\"cards\" class=\"cards\" aria-live=\"polite\"></ul>\n </section>\n\n <aside>\n <section class=\"panel\" aria-labelledby=\"peers-heading\">\n <h2 id=\"peers-heading\">Live peers</h2>\n <p class=\"muted\">Pointers use document-normalized coordinates and line up across viewports.</p>\n <ul id=\"peers\" class=\"peers\" aria-live=\"polite\"></ul>\n </section>\n\n <section class=\"panel\" aria-labelledby=\"agent-note-heading\">\n <h2 id=\"agent-note-heading\">Agent note</h2>\n <div id=\"agent-note\" class=\"agent-note\" aria-live=\"polite\"></div>\n </section>\n </aside>\n </div>\n\n <section class=\"panel\" aria-labelledby=\"lobby-heading\">\n <h2 id=\"lobby-heading\">Lobby</h2>\n <p class=\"room-meta\">Room: lobby · Schema: 1</p>\n <form id=\"lobby-form\">\n <label for=\"lobby-message\">Shared lobby message</label>\n <textarea id=\"lobby-message\" maxlength=\"1000\" placeholder=\"Leave a message for everyone in the lobby\"></textarea>\n <button type=\"submit\">Save lobby message</button>\n <p id=\"lobby-save-state\" class=\"save-state\" aria-live=\"polite\"></p>\n </form>\n </section>\n </main>\n\n <script>\n (() => {\n const ui = {\n viewerName: document.querySelector('#viewer-name'),\n notice: document.querySelector('#notice'),\n cards: document.querySelector('#cards'),\n peers: document.querySelector('#peers'),\n agentNote: document.querySelector('#agent-note'),\n addCardForm: document.querySelector('#add-card-form'),\n cardTitle: document.querySelector('#card-title'),\n lobbyForm: document.querySelector('#lobby-form'),\n lobbyMessage: document.querySelector('#lobby-message'),\n lobbySaveState: document.querySelector('#lobby-save-state')\n };\n\n let board;\n let lobby;\n let peers = [];\n let lobbyDirty = false;\n\n function message(error) {\n return error && error.message ? error.message : String(error);\n }\n\n function showNotice(text, tone) {\n ui.notice.textContent = text || '';\n ui.notice.dataset.tone = tone || '';\n }\n\n function markStale(room, roomName) {\n if (!room.stale) return false;\n showNotice(`${roomName} uses a different schema version. Its saved state remains visible, but this page cannot write changes.`, 'stale');\n return true;\n }\n\n function renderBoard(state) {\n const cards = state && state.cards && typeof state.cards === 'object' ? state.cards : {};\n ui.cards.replaceChildren();\n const entries = Object.entries(cards);\n\n if (!entries.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No cards yet. Add the first card.';\n ui.cards.append(empty);\n }\n\n for (const [id, card] of entries) {\n const item = document.createElement('li');\n item.className = 'card';\n const title = document.createElement('span');\n title.className = 'card-title';\n title.textContent = card && typeof card.title === 'string' ? card.title : 'Untitled card';\n const toggle = document.createElement('button');\n const done = Boolean(card && card.done);\n toggle.className = 'card-status';\n toggle.dataset.done = String(done);\n toggle.type = 'button';\n toggle.textContent = done ? 'Done' : 'Open';\n toggle.setAttribute('aria-label', `Mark ${title.textContent} as ${done ? 'open' : 'done'}`);\n toggle.addEventListener('click', () => toggleCard(id));\n item.append(title, toggle);\n ui.cards.append(item);\n }\n\n ui.agentNote.textContent = state && typeof state.agentNote === 'string' ? state.agentNote : '';\n }\n\n function renderPeers(nextPeers) {\n peers = Array.isArray(nextPeers) ? nextPeers : [];\n ui.peers.replaceChildren();\n\n if (!peers.length) {\n const empty = document.createElement('li');\n empty.className = 'empty';\n empty.textContent = 'No other peers are connected.';\n ui.peers.append(empty);\n return;\n }\n\n for (const peer of peers) {\n const item = document.createElement('li');\n item.className = 'peer';\n const name = document.createElement('span');\n name.className = 'peer-name';\n name.textContent = peer.name || 'Guest';\n item.append(name);\n ui.peers.append(item);\n }\n }\n\n function renderLobby(state) {\n if (lobbyDirty) return;\n ui.lobbyMessage.value = state && typeof state.message === 'string' ? state.message : '';\n }\n\n async function addCard(title) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n state.cards ??= {};\n state.cards[crypto.randomUUID()] = { title, done: false };\n });\n ui.cardTitle.value = '';\n showNotice('Card added.', 'success');\n } catch (error) {\n showNotice(`Could not add the card: ${message(error)}`, 'error');\n }\n }\n\n async function toggleCard(id) {\n if (markStale(board, 'match-42')) return;\n try {\n await board.change(state => {\n const card = state.cards && state.cards[id];\n if (card) card.done = !card.done;\n });\n } catch (error) {\n showNotice(`Could not update the card: ${message(error)}`, 'error');\n }\n }\n\n async function saveLobbyMessage() {\n if (markStale(lobby, 'lobby')) return;\n try {\n const value = ui.lobbyMessage.value;\n await lobby.change(state => {\n state.message = value;\n });\n lobbyDirty = false;\n ui.lobbySaveState.textContent = 'Saved to the lobby room.';\n } catch (error) {\n ui.lobbySaveState.textContent = `Could not save: ${message(error)}`;\n showNotice(`Could not save the lobby message: ${message(error)}`, 'error');\n }\n }\n\n async function initialize() {\n try {\n board = await window.swarmSdk.room('match-42', { schemaVersion: 1 });\n lobby = await window.swarmSdk.room('lobby', { schemaVersion: 1 });\n\n const viewer = board.me || lobby.me || {};\n ui.viewerName.textContent = viewer.name || 'Guest';\n\n board.on('change', renderBoard);\n board.on('presence', renderPeers);\n board.on('error', error => showNotice(`match-42 error: ${message(error)}`, 'error'));\n board.on('reset', () => showNotice('match-42 was reset by another participant. The board has resynchronized.', 'stale'));\n lobby.on('change', renderLobby);\n lobby.on('error', error => showNotice(`lobby error: ${message(error)}`, 'error'));\n lobby.on('reset', () => showNotice('lobby was reset by another participant. The lobby has resynchronized.', 'stale'));\n\n renderBoard(board.state);\n renderPeers(board.presence.peers);\n renderLobby(lobby.state);\n markStale(board, 'match-42');\n markStale(lobby, 'lobby');\n\n if (!board.stale) {\n await board.change(state => {\n state.cards ??= {};\n state.agentNote ??= '';\n });\n }\n if (!lobby.stale) {\n await lobby.change(state => {\n state.message ??= '';\n state.activeMatch ??= 'match-42';\n });\n }\n } catch (error) {\n showNotice(`Could not connect to the shared rooms: ${message(error)}`, 'error');\n ui.viewerName.textContent = 'Unavailable';\n }\n }\n\n ui.addCardForm.addEventListener('submit', event => {\n event.preventDefault();\n const title = ui.cardTitle.value.trim();\n if (title) addCard(title);\n });\n ui.lobbyMessage.addEventListener('input', () => { lobbyDirty = true; });\n ui.lobbyForm.addEventListener('submit', event => {\n event.preventDefault();\n saveLobbyMessage();\n });\n window.addEventListener('pagehide', () => {\n if (board) board.close();\n if (lobby) lobby.close();\n });\n\n initialize();\n })();\n </script>\n </body>\n</html>\n"
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
"path": "examples/report-page.html",
|
package/src/be/steering.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type Agent,
|
|
2
3
|
type AgentTask,
|
|
3
4
|
type OnUnsupported,
|
|
4
5
|
PROVIDER_STEER_CAPABILITIES,
|
|
@@ -53,11 +54,14 @@ async function providerForTask(task: AgentTask): Promise<ProviderName> {
|
|
|
53
54
|
return agent?.harnessProvider ?? agent?.provider ?? task.provider ?? "claude";
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
export
|
|
57
|
+
export interface TaskSteeringFields {
|
|
57
58
|
isLeadTask: boolean;
|
|
58
59
|
supportedSteerModes: SteerMode[];
|
|
59
|
-
}
|
|
60
|
-
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type SteeringAgent = Pick<Agent, "isLead" | "provider" | "harnessProvider">;
|
|
63
|
+
|
|
64
|
+
function steeringFieldsFor(task: AgentTask, agent: SteeringAgent | null): TaskSteeringFields {
|
|
61
65
|
const provider = agent?.harnessProvider ?? agent?.provider ?? task.provider;
|
|
62
66
|
return {
|
|
63
67
|
isLeadTask: agent?.isLead ?? false,
|
|
@@ -65,6 +69,52 @@ export async function getTaskSteeringFields(task: AgentTask): Promise<{
|
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
export async function getTaskSteeringFields(task: AgentTask): Promise<TaskSteeringFields> {
|
|
73
|
+
const agent = task.agentId ? await getAgentById(task.agentId) : null;
|
|
74
|
+
return steeringFieldsFor(task, agent);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Bound-parameter chunk for `IN (...)`, well under SQLite's variable limit. */
|
|
78
|
+
const AGENT_ID_CHUNK = 500;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Batched {@link getTaskSteeringFields}: one query per 500 distinct assignees
|
|
82
|
+
* instead of one full agent row per task. Selects only the three columns the
|
|
83
|
+
* steering fields read. Keyed by task id.
|
|
84
|
+
*/
|
|
85
|
+
export async function getTaskSteeringFieldsForTasks(
|
|
86
|
+
tasks: AgentTask[],
|
|
87
|
+
): Promise<Map<string, TaskSteeringFields>> {
|
|
88
|
+
const agentIds = [...new Set(tasks.flatMap((task) => (task.agentId ? [task.agentId] : [])))];
|
|
89
|
+
const agents = new Map<string, SteeringAgent>();
|
|
90
|
+
for (let i = 0; i < agentIds.length; i += AGENT_ID_CHUNK) {
|
|
91
|
+
const ids = agentIds.slice(i, i + AGENT_ID_CHUNK);
|
|
92
|
+
const rows = await getDbClient().query<{
|
|
93
|
+
id: string;
|
|
94
|
+
isLead: number;
|
|
95
|
+
provider: string | null;
|
|
96
|
+
harness_provider: string | null;
|
|
97
|
+
}>(
|
|
98
|
+
`SELECT id, isLead, provider, harness_provider FROM agents WHERE id IN (${ids.map(() => "?").join(",")})`,
|
|
99
|
+
ids,
|
|
100
|
+
);
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
// Same mapping as rowToAgent for these three fields.
|
|
103
|
+
agents.set(row.id, {
|
|
104
|
+
isLead: row.isLead === 1,
|
|
105
|
+
provider: (row.provider as ProviderName | null) ?? undefined,
|
|
106
|
+
harnessProvider: (row.harness_provider as ProviderName | null) ?? null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return new Map(
|
|
111
|
+
tasks.map((task) => [
|
|
112
|
+
task.id,
|
|
113
|
+
steeringFieldsFor(task, task.agentId ? (agents.get(task.agentId) ?? null) : null),
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
68
118
|
/**
|
|
69
119
|
* Convert an undeliverable steering message into a normal follow-up task.
|
|
70
120
|
* Step 2 reuses this seam for pending rows discovered during terminal sweeps.
|
|
@@ -299,6 +299,7 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
299
299
|
"BUDGET_ADMISSION_DISABLED",
|
|
300
300
|
"MCP_OAUTH_ALLOW_PRIVATE_HOSTS",
|
|
301
301
|
"OTEL_TRACE_POLL",
|
|
302
|
+
"OTEL_EXPORT_API_LOGS",
|
|
302
303
|
"ANONYMIZED_TELEMETRY",
|
|
303
304
|
"SWARM_HIDE_CLOUD_PROMO",
|
|
304
305
|
"DB_QUERY_BOUNDED_ENABLED",
|
|
@@ -358,6 +359,8 @@ const VALIDATED_KEYS: Record<string, ConfigValidator> = {
|
|
|
358
359
|
),
|
|
359
360
|
// 0 is meaningful here: "auto-assign nothing this sweep".
|
|
360
361
|
...integerValidators(["HEARTBEAT_MAX_AUTO_ASSIGN"], 0),
|
|
362
|
+
// 0 turns approval auto-cancellation off.
|
|
363
|
+
...integerValidators(["APPROVAL_REQUEST_AUTO_CANCELLATION_DAYS"], 0),
|
|
361
364
|
// Below ~100 tokens the preamble can't fit a useful summary; above 20000
|
|
362
365
|
// (~80k chars) it risks the SIGTERM-143 context-saturation failure mode
|
|
363
366
|
// the cap exists to prevent (see context-preamble.ts).
|
package/src/be/task-citations.ts
CHANGED
|
@@ -145,6 +145,35 @@ export async function getTaskCitations(taskId: string): Promise<TaskCitation[]>
|
|
|
145
145
|
return rows.map((row) => ({ ...row, general: row.general === 1 }));
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Bound-parameter chunk for `IN (...)`, well under SQLite's variable limit. */
|
|
149
|
+
const TASK_ID_CHUNK = 500;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Batched {@link getTaskCitations}: one query per 500 task ids. Every
|
|
153
|
+
* requested id is a key; each list keeps `citation_index` order.
|
|
154
|
+
*/
|
|
155
|
+
export async function getTaskCitationsForTasks(
|
|
156
|
+
taskIds: string[],
|
|
157
|
+
): Promise<Map<string, TaskCitation[]>> {
|
|
158
|
+
const ids = [...new Set(taskIds)];
|
|
159
|
+
const byTask = new Map<string, TaskCitation[]>(ids.map((id) => [id, []]));
|
|
160
|
+
for (let i = 0; i < ids.length; i += TASK_ID_CHUNK) {
|
|
161
|
+
const chunk = ids.slice(i, i + TASK_ID_CHUNK);
|
|
162
|
+
const rows = await getDbClient().query<
|
|
163
|
+
Omit<TaskCitation, "general"> & { general: number; taskId: string }
|
|
164
|
+
>(
|
|
165
|
+
`SELECT task_id AS taskId, citation_index AS "index", kind, ref, label, quote,
|
|
166
|
+
resolved_url AS resolvedUrl, verified, general FROM task_citations
|
|
167
|
+
WHERE task_id IN (${chunk.map(() => "?").join(",")}) ORDER BY task_id, citation_index`,
|
|
168
|
+
chunk,
|
|
169
|
+
);
|
|
170
|
+
for (const { taskId, ...row } of rows) {
|
|
171
|
+
byTask.get(taskId)?.push({ ...row, general: row.general === 1 });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return byTask;
|
|
175
|
+
}
|
|
176
|
+
|
|
148
177
|
/**
|
|
149
178
|
* The completion citation check refuses at most once per task. The refusal
|
|
150
179
|
* is an `agent_log` row, so it also shows in the task's activity timeline.
|
package/src/cli.tsx
CHANGED
|
@@ -39,6 +39,7 @@ interface ParsedArgs {
|
|
|
39
39
|
preset: string;
|
|
40
40
|
maxConcurrentTasks: string | undefined;
|
|
41
41
|
pullPolicy: string | undefined;
|
|
42
|
+
containerEngine: string | undefined;
|
|
42
43
|
open: boolean;
|
|
43
44
|
showHelp: boolean;
|
|
44
45
|
dbPath: string;
|
|
@@ -60,6 +61,7 @@ function parseArgs(args: string[]): ParsedArgs {
|
|
|
60
61
|
let preset = "";
|
|
61
62
|
let maxConcurrentTasks: string | undefined;
|
|
62
63
|
let pullPolicy: string | undefined;
|
|
64
|
+
let containerEngine: string | undefined;
|
|
63
65
|
let open = false;
|
|
64
66
|
let showHelp = false;
|
|
65
67
|
let dbPath = "";
|
|
@@ -113,6 +115,12 @@ function parseArgs(args: string[]): ParsedArgs {
|
|
|
113
115
|
if (value && !value.startsWith("-")) i++;
|
|
114
116
|
} else if (arg?.startsWith("--pull-policy=")) {
|
|
115
117
|
pullPolicy = arg.slice("--pull-policy=".length);
|
|
118
|
+
} else if (arg === "--container-engine") {
|
|
119
|
+
const value = mainArgs[i + 1];
|
|
120
|
+
containerEngine = value && !value.startsWith("-") ? value : "";
|
|
121
|
+
if (value && !value.startsWith("-")) i++;
|
|
122
|
+
} else if (arg?.startsWith("--container-engine=")) {
|
|
123
|
+
containerEngine = arg.slice("--container-engine=".length);
|
|
116
124
|
} else if (arg === "--open") {
|
|
117
125
|
open = true;
|
|
118
126
|
} else if (arg === "--help" || arg === "-h") {
|
|
@@ -139,6 +147,7 @@ function parseArgs(args: string[]): ParsedArgs {
|
|
|
139
147
|
preset,
|
|
140
148
|
maxConcurrentTasks,
|
|
141
149
|
pullPolicy,
|
|
150
|
+
containerEngine,
|
|
142
151
|
open,
|
|
143
152
|
showHelp,
|
|
144
153
|
dbPath,
|
|
@@ -154,13 +163,14 @@ const COMMAND_HELP: Record<
|
|
|
154
163
|
onboard: {
|
|
155
164
|
usage: `${binName} onboard [options]`,
|
|
156
165
|
description:
|
|
157
|
-
"Set up a new swarm from scratch using Docker Compose.\nInteractive wizard that collects credentials, generates docker-compose.yml + .env,\nstarts the stack, and verifies health.",
|
|
166
|
+
"Set up a new swarm from scratch using Docker Compose (or Podman with a Compose provider).\nInteractive wizard that collects credentials, generates docker-compose.yml + .env,\nstarts the stack, and verifies health.",
|
|
158
167
|
options: [
|
|
159
168
|
" --dry-run Preview what would be generated without writing",
|
|
160
169
|
" -y, --yes Non-interactive mode (reads from env vars)",
|
|
161
170
|
" --preset <name> Preset: full, dev, content, research, solo (required with --yes)",
|
|
162
171
|
" --max-concurrent-tasks <n> Tasks per agent (1-100; default: lead 2, worker 1)",
|
|
163
172
|
" --pull-policy <policy> Pull policy: always, missing, never (default: always)",
|
|
173
|
+
" --container-engine <engine> auto, docker, podman (default: $AGENT_SWARM_CONTAINER_ENGINE, else auto; auto prefers Docker)",
|
|
164
174
|
" -h, --help Show this help",
|
|
165
175
|
].join("\n"),
|
|
166
176
|
examples: [
|
|
@@ -168,6 +178,7 @@ const COMMAND_HELP: Record<
|
|
|
168
178
|
` ${binName} onboard --dry-run`,
|
|
169
179
|
` ${binName} onboard --yes --preset=full`,
|
|
170
180
|
` ${binName} onboard --yes --preset=dev`,
|
|
181
|
+
` ${binName} onboard --container-engine=podman`,
|
|
171
182
|
` ANTHROPIC_API_KEY=sk-... ${binName} onboard --yes --preset=solo`,
|
|
172
183
|
].join("\n"),
|
|
173
184
|
},
|
|
@@ -647,6 +658,7 @@ function App({ args }: { args: ParsedArgs }) {
|
|
|
647
658
|
preset,
|
|
648
659
|
maxConcurrentTasks,
|
|
649
660
|
pullPolicy,
|
|
661
|
+
containerEngine,
|
|
650
662
|
} = args;
|
|
651
663
|
|
|
652
664
|
switch (command) {
|
|
@@ -660,6 +672,7 @@ function App({ args }: { args: ParsedArgs }) {
|
|
|
660
672
|
preset: preset || undefined,
|
|
661
673
|
maxConcurrentTasks,
|
|
662
674
|
pullPolicy,
|
|
675
|
+
containerEngine,
|
|
663
676
|
}}
|
|
664
677
|
/>
|
|
665
678
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { composeCommandText, engineForCommands } from "./container-engine.ts";
|
|
1
2
|
import { expandServices } from "./service-names.ts";
|
|
2
3
|
import type { OnboardState } from "./types.ts";
|
|
3
4
|
|
|
@@ -52,6 +53,9 @@ const ENV_AWS_PROFILE = " - AWS_PROFILE=${AWS_PROFILE}";
|
|
|
52
53
|
const ENV_BEDROCK_AUTH_MODE = " - BEDROCK_AUTH_MODE=${BEDROCK_AUTH_MODE}";
|
|
53
54
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
54
55
|
const AWS_PROFILE_VOLUME = " - ${HOME}/.aws:/home/worker/.aws:ro";
|
|
56
|
+
// `z` relabels the bind mount for SELinux as shared, since every worker mounts it.
|
|
57
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
58
|
+
const AWS_PROFILE_VOLUME_SELINUX = " - ${HOME}/.aws:/home/worker/.aws:ro,z";
|
|
55
59
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
56
60
|
const ENV_GITHUB_TOKEN = " - GITHUB_TOKEN=${GITHUB_TOKEN}";
|
|
57
61
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose env var syntax
|
|
@@ -111,11 +115,12 @@ export function generateCompose(state: OnboardState): string {
|
|
|
111
115
|
const lines: string[] = [];
|
|
112
116
|
|
|
113
117
|
// Header
|
|
114
|
-
|
|
118
|
+
const engine = engineForCommands(state.containerEngine);
|
|
119
|
+
lines.push(`# ${engine === "podman" ? "Podman" : "Docker"} Compose for Agent Swarm`);
|
|
115
120
|
lines.push("# Generated by `agent-swarm onboard`");
|
|
116
121
|
lines.push("#");
|
|
117
122
|
lines.push("# Usage:");
|
|
118
|
-
lines.push(
|
|
123
|
+
lines.push(`# ${composeCommandText(engine, "--env-file .env up -d")}`);
|
|
119
124
|
lines.push("#");
|
|
120
125
|
lines.push("# Image version: set AGENT_SWARM_VERSION in .env to a published release.");
|
|
121
126
|
lines.push("# Blank falls back to `latest`, which moves on every commit to main and is");
|
|
@@ -242,7 +247,7 @@ export function generateCompose(state: OnboardState): string {
|
|
|
242
247
|
lines.push(" - swarm_shared:/app/shared");
|
|
243
248
|
lines.push(` - swarm_${svc.sanitizedName}:/app/agent`);
|
|
244
249
|
if (state.provider === "bedrock" && state.awsProfile) {
|
|
245
|
-
lines.push(AWS_PROFILE_VOLUME);
|
|
250
|
+
lines.push(engine === "podman" ? AWS_PROFILE_VOLUME_SELINUX : AWS_PROFILE_VOLUME);
|
|
246
251
|
}
|
|
247
252
|
lines.push("");
|
|
248
253
|
lines.push(" restart: unless-stopped");
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container engine selection for `agent-swarm onboard`.
|
|
3
|
+
*
|
|
4
|
+
* The generated stack is plain OCI images plus a Compose file, so it runs under
|
|
5
|
+
* Docker Compose or under Podman with a Compose provider (`podman compose`).
|
|
6
|
+
* `auto` keeps the historical behavior: Docker whenever the `docker` binary is
|
|
7
|
+
* present, Podman only as a fallback.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ContainerEngine = "docker" | "podman";
|
|
11
|
+
export type ContainerEnginePreference = "auto" | ContainerEngine;
|
|
12
|
+
|
|
13
|
+
export const CONTAINER_ENGINE_PREFERENCES: readonly ContainerEnginePreference[] = [
|
|
14
|
+
"auto",
|
|
15
|
+
"docker",
|
|
16
|
+
"podman",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const CONTAINER_ENGINE_LABELS: Record<ContainerEngine, string> = {
|
|
20
|
+
docker: "Docker",
|
|
21
|
+
podman: "Podman",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const ENGINE_INSTALL_HINTS: Record<ContainerEngine, string> = {
|
|
25
|
+
docker: "Install: brew install --cask docker (macOS) or https://docs.docker.com/get-docker/",
|
|
26
|
+
podman: "Install: https://podman.io/docs/installation",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const PODMAN_COMPOSE_PROVIDER_HINT =
|
|
30
|
+
"`podman compose` needs an external Compose provider. Install podman-compose (dnf/apt install podman-compose, or pipx install podman-compose) or docker-compose v2, or point PODMAN_COMPOSE_PROVIDER at one. See https://docs.agent-swarm.dev/docs/guides/podman-rootless";
|
|
31
|
+
|
|
32
|
+
export function isContainerEnginePreference(value: string): value is ContainerEnginePreference {
|
|
33
|
+
return (CONTAINER_ENGINE_PREFERENCES as readonly string[]).includes(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Env var that selects the engine when `--container-engine` is not passed. */
|
|
37
|
+
export const CONTAINER_ENGINE_ENV = "AGENT_SWARM_CONTAINER_ENGINE";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pick the engine preference: the `--container-engine` flag, then
|
|
41
|
+
* `AGENT_SWARM_CONTAINER_ENGINE`, then `auto`. An empty env var counts as unset.
|
|
42
|
+
* An invalid value from either source is an error, never a silent `auto`.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveContainerEnginePreference(
|
|
45
|
+
flag: string | undefined,
|
|
46
|
+
env: Record<string, string | undefined>,
|
|
47
|
+
): { ok: true; value: ContainerEnginePreference } | { ok: false; error: string } {
|
|
48
|
+
const options = CONTAINER_ENGINE_PREFERENCES.join(", ");
|
|
49
|
+
if (flag !== undefined) {
|
|
50
|
+
if (!isContainerEnginePreference(flag)) {
|
|
51
|
+
return { ok: false, error: `Invalid container engine "${flag}". Options: ${options}` };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, value: flag };
|
|
54
|
+
}
|
|
55
|
+
const fromEnv = env[CONTAINER_ENGINE_ENV];
|
|
56
|
+
if (fromEnv === undefined || fromEnv === "") return { ok: true, value: "auto" };
|
|
57
|
+
if (!isContainerEnginePreference(fromEnv)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: `Invalid ${CONTAINER_ENGINE_ENV} "${fromEnv}". Options: ${options}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, value: fromEnv };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Engine to use for commands and generated instructions when detection has not
|
|
68
|
+
* run yet. `auto` renders as Docker, matching its preference order.
|
|
69
|
+
*/
|
|
70
|
+
export function engineForCommands(preference: ContainerEnginePreference): ContainerEngine {
|
|
71
|
+
return preference === "podman" ? "podman" : "docker";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `docker compose <args>` / `podman compose <args>` as an argv array. */
|
|
75
|
+
export function composeArgv(engine: ContainerEngine, args: string[]): string[] {
|
|
76
|
+
return [engine, "compose", ...args];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Human-readable compose command for logs and printed instructions. */
|
|
80
|
+
export function composeCommandText(engine: ContainerEngine, args: string): string {
|
|
81
|
+
return `${engine} compose ${args}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface CommandResult {
|
|
85
|
+
exitCode: number;
|
|
86
|
+
stdout: string;
|
|
87
|
+
stderr: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type CommandRunner = (argv: string[], options?: { cwd?: string }) => Promise<CommandResult>;
|
|
91
|
+
|
|
92
|
+
/** Runs a command without throwing. A binary that cannot be spawned reports exit code 127. */
|
|
93
|
+
export const defaultCommandRunner: CommandRunner = async (argv, options) => {
|
|
94
|
+
try {
|
|
95
|
+
const proc = Bun.spawn(argv, {
|
|
96
|
+
cwd: options?.cwd,
|
|
97
|
+
stdout: "pipe",
|
|
98
|
+
stderr: "pipe",
|
|
99
|
+
stdin: "ignore",
|
|
100
|
+
});
|
|
101
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
102
|
+
new Response(proc.stdout).text(),
|
|
103
|
+
new Response(proc.stderr).text(),
|
|
104
|
+
proc.exited,
|
|
105
|
+
]);
|
|
106
|
+
return { exitCode, stdout, stderr };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return {
|
|
109
|
+
exitCode: 127,
|
|
110
|
+
stdout: "",
|
|
111
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export interface EngineCheck {
|
|
117
|
+
ok: boolean;
|
|
118
|
+
version?: string;
|
|
119
|
+
error?: string;
|
|
120
|
+
hint?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface EngineProbe {
|
|
124
|
+
engine: ContainerEngine;
|
|
125
|
+
binary: EngineCheck;
|
|
126
|
+
compose: EngineCheck;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type EngineResolution =
|
|
130
|
+
| { ok: true; engine: ContainerEngine; probe: EngineProbe }
|
|
131
|
+
| { ok: false; engine: ContainerEngine | null; error: string; probe: EngineProbe | null };
|
|
132
|
+
|
|
133
|
+
function firstLine(text: string): string {
|
|
134
|
+
return text.trim().split("\n")[0]?.trim() ?? "";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function checkBinary(engine: ContainerEngine, run: CommandRunner): Promise<EngineCheck> {
|
|
138
|
+
const label = CONTAINER_ENGINE_LABELS[engine];
|
|
139
|
+
const result = await run([engine, "--version"]);
|
|
140
|
+
if (result.exitCode === 0) return { ok: true, version: firstLine(result.stdout) };
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: result.exitCode === 127 ? `${label} not found` : `${label} exited with non-zero status`,
|
|
144
|
+
hint: ENGINE_INSTALL_HINTS[engine],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function checkCompose(engine: ContainerEngine, run: CommandRunner): Promise<EngineCheck> {
|
|
149
|
+
const result = await run(composeArgv(engine, ["version"]));
|
|
150
|
+
if (result.exitCode === 0) {
|
|
151
|
+
// `podman compose` prints its provider banner on stderr; the version is on stdout.
|
|
152
|
+
return { ok: true, version: firstLine(result.stdout) || firstLine(result.stderr) };
|
|
153
|
+
}
|
|
154
|
+
if (engine === "podman") {
|
|
155
|
+
const detail = firstLine(result.stderr) || firstLine(result.stdout);
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
error: `podman compose has no working Compose provider${detail ? ` (${detail})` : ""}`,
|
|
159
|
+
hint: PODMAN_COMPOSE_PROVIDER_HINT,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: "Docker Compose v2 not found",
|
|
165
|
+
hint: "Install the Compose v2 plugin: https://docs.docker.com/compose/install/",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Check one engine: the binary first, then its Compose subcommand. */
|
|
170
|
+
export async function probeEngine(
|
|
171
|
+
engine: ContainerEngine,
|
|
172
|
+
run: CommandRunner = defaultCommandRunner,
|
|
173
|
+
): Promise<EngineProbe> {
|
|
174
|
+
const binary = await checkBinary(engine, run);
|
|
175
|
+
if (!binary.ok) {
|
|
176
|
+
return { engine, binary, compose: { ok: false, error: "skipped: engine unavailable" } };
|
|
177
|
+
}
|
|
178
|
+
return { engine, binary, compose: await checkCompose(engine, run) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function probeError(probe: EngineProbe): string {
|
|
182
|
+
if (!probe.binary.ok) return probe.binary.error ?? "engine unavailable";
|
|
183
|
+
return probe.compose.error ?? "Compose unavailable";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the engine to use. An explicit choice never falls back to the other
|
|
188
|
+
* engine. `auto` picks Docker whenever the `docker` binary exists (so a broken
|
|
189
|
+
* Docker Compose still reports as a Docker problem, as before) and only tries
|
|
190
|
+
* Podman when Docker is absent.
|
|
191
|
+
*/
|
|
192
|
+
export async function resolveContainerEngine(
|
|
193
|
+
preference: ContainerEnginePreference,
|
|
194
|
+
run: CommandRunner = defaultCommandRunner,
|
|
195
|
+
): Promise<EngineResolution> {
|
|
196
|
+
if (preference !== "auto") {
|
|
197
|
+
const probe = await probeEngine(preference, run);
|
|
198
|
+
if (probe.binary.ok && probe.compose.ok) return { ok: true, engine: preference, probe };
|
|
199
|
+
return { ok: false, engine: preference, error: probeError(probe), probe };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const docker = await probeEngine("docker", run);
|
|
203
|
+
if (docker.binary.ok) {
|
|
204
|
+
if (docker.compose.ok) return { ok: true, engine: "docker", probe: docker };
|
|
205
|
+
return { ok: false, engine: "docker", error: probeError(docker), probe: docker };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const podman = await probeEngine("podman", run);
|
|
209
|
+
if (podman.binary.ok) {
|
|
210
|
+
if (podman.compose.ok) return { ok: true, engine: "podman", probe: podman };
|
|
211
|
+
return { ok: false, engine: "podman", error: probeError(podman), probe: podman };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
engine: null,
|
|
217
|
+
error: "Neither Docker nor Podman was found",
|
|
218
|
+
probe: docker,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -18,6 +18,7 @@ export function generateManifest(state: OnboardState): object {
|
|
|
18
18
|
providerLabel: PROVIDER_LABELS[state.provider],
|
|
19
19
|
providerNotice: state.provider === "bedrock" ? BEDROCK_ALPHA_NOTICE : undefined,
|
|
20
20
|
harness: state.harness,
|
|
21
|
+
containerEngine: state.containerEngine,
|
|
21
22
|
services: expanded.map((svc) => ({
|
|
22
23
|
name: svc.name,
|
|
23
24
|
templateId: svc.entry.template,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Select, Spinner } from "@inkjs/ui";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { composeCommandText, engineForCommands } from "../container-engine.ts";
|
|
4
5
|
import type { StepProps } from "../types.ts";
|
|
5
6
|
|
|
6
7
|
type HealthStatus = "waiting_api" | "waiting_agents" | "healthy" | "timeout";
|
|
@@ -148,7 +149,9 @@ export function HealthCheckStep({ state, addLog, goToNext }: StepProps) {
|
|
|
148
149
|
return (
|
|
149
150
|
<Box flexDirection="column" padding={1}>
|
|
150
151
|
<Text color="red">{"✗"} API health check timed out after 60s</Text>
|
|
151
|
-
<Text dimColor>
|
|
152
|
+
<Text dimColor>
|
|
153
|
+
Try: {composeCommandText(engineForCommands(state.containerEngine), "logs --tail 50")}
|
|
154
|
+
</Text>
|
|
152
155
|
<Box marginTop={1}>
|
|
153
156
|
<Select
|
|
154
157
|
options={[
|