@desplega.ai/agent-swarm 1.75.0 → 1.76.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +973 -36
  3. package/package.json +2 -2
  4. package/src/be/db.ts +527 -9
  5. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  6. package/src/be/memory/raters/llm.ts +56 -75
  7. package/src/be/memory/retrieval-store.ts +21 -0
  8. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  9. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  10. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  11. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  12. package/src/be/migrations/058_task_templates.sql +31 -0
  13. package/src/be/swarm-config-guard.ts +24 -0
  14. package/src/commands/credential-wait.ts +1 -1
  15. package/src/commands/provider-credentials.ts +434 -0
  16. package/src/commands/runner.ts +229 -42
  17. package/src/hooks/hook.ts +115 -95
  18. package/src/http/agents.ts +82 -2
  19. package/src/http/config.ts +11 -1
  20. package/src/http/inbox-state.ts +89 -0
  21. package/src/http/index.ts +10 -0
  22. package/src/http/sessions.ts +86 -0
  23. package/src/http/status.ts +665 -0
  24. package/src/http/task-templates.ts +51 -0
  25. package/src/http/tasks.ts +85 -5
  26. package/src/http/users.ts +134 -0
  27. package/src/providers/claude-adapter.ts +5 -0
  28. package/src/providers/codex-adapter.ts +1 -1
  29. package/src/providers/index.ts +1 -1
  30. package/src/slack/handlers.ts +0 -1
  31. package/src/tests/agents-harness-provider.test.ts +333 -0
  32. package/src/tests/credential-check.test.ts +32 -1
  33. package/src/tests/credential-status-api.test.ts +42 -0
  34. package/src/tests/harness-provider-resolution.test.ts +242 -0
  35. package/src/tests/jira-sync.test.ts +1 -1
  36. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  37. package/src/tests/memory-rater-llm.test.ts +265 -107
  38. package/src/tests/migration-runner-regressions.test.ts +17 -2
  39. package/src/tests/sessions.test.ts +141 -0
  40. package/src/tests/status.test.ts +843 -0
  41. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  42. package/src/tests/template-recommendations.test.ts +148 -0
  43. package/src/tests/use-dismissible-card.test.ts +140 -0
  44. package/src/tools/swarm-config/set-config.ts +17 -1
  45. package/src/types.ts +117 -0
  46. package/src/utils/harness-provider.ts +32 -0
  47. package/tsconfig.json +0 -2
  48. package/src/providers/credentials.ts +0 -74
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Stop-hook task-context resolution.
3
+ *
4
+ * Regression for the silent-drop bug PR #444's gate trace surfaced: every Stop
5
+ * hook logged `hasTaskId: false` because TASK_FILE on disk had been cleaned up
6
+ * mid-session, so `Bun.file(taskFile).text()` threw and the catch swallowed it.
7
+ * Fix: prefer the AGENT_SWARM_TASK_ID env var (set by `claude-adapter.ts`) and
8
+ * only fall back to the file. See `resolveStopHookTaskContext` in hook.ts.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import { unlink } from "node:fs/promises";
12
+ import { resolveStopHookTaskContext } from "../hooks/hook";
13
+
14
+ describe("resolveStopHookTaskContext", () => {
15
+ test("prefers AGENT_SWARM_TASK_ID env var when TASK_FILE is missing on disk", async () => {
16
+ const missingPath = `/tmp/stop-hook-missing-${Date.now()}.json`;
17
+ // Sanity: file must not exist.
18
+ try {
19
+ await unlink(missingPath);
20
+ } catch {}
21
+
22
+ const { taskContext, taskId } = await resolveStopHookTaskContext({
23
+ AGENT_SWARM_TASK_ID: "task-from-env-123",
24
+ TASK_FILE: missingPath,
25
+ });
26
+
27
+ expect(taskId).toBe("task-from-env-123");
28
+ // taskContext stays empty because the file (which carries the human task
29
+ // text) wasn't readable. That's fine — the LLM rater only needs taskId.
30
+ expect(taskContext).toBe("");
31
+ });
32
+
33
+ test("env var alone (no TASK_FILE) still populates taskId", async () => {
34
+ const { taskContext, taskId } = await resolveStopHookTaskContext({
35
+ AGENT_SWARM_TASK_ID: "task-env-only",
36
+ });
37
+ expect(taskId).toBe("task-env-only");
38
+ expect(taskContext).toBe("");
39
+ });
40
+
41
+ test("falls back to TASK_FILE.id when env var unset", async () => {
42
+ const path = `/tmp/stop-hook-file-${Date.now()}.json`;
43
+ await Bun.write(path, JSON.stringify({ id: "task-from-file-456", task: "do the thing" }));
44
+ try {
45
+ const { taskContext, taskId } = await resolveStopHookTaskContext({
46
+ TASK_FILE: path,
47
+ });
48
+ expect(taskId).toBe("task-from-file-456");
49
+ expect(taskContext).toBe("Task: do the thing");
50
+ } finally {
51
+ await unlink(path).catch(() => {});
52
+ }
53
+ });
54
+
55
+ test("env var wins over TASK_FILE.id but file still seeds taskContext", async () => {
56
+ const path = `/tmp/stop-hook-both-${Date.now()}.json`;
57
+ await Bun.write(path, JSON.stringify({ id: "task-from-file", task: "human task text" }));
58
+ try {
59
+ const { taskContext, taskId } = await resolveStopHookTaskContext({
60
+ AGENT_SWARM_TASK_ID: "task-from-env",
61
+ TASK_FILE: path,
62
+ });
63
+ expect(taskId).toBe("task-from-env");
64
+ expect(taskContext).toBe("Task: human task text");
65
+ } finally {
66
+ await unlink(path).catch(() => {});
67
+ }
68
+ });
69
+
70
+ test("missing file with no env var → both undefined/empty (no throw)", async () => {
71
+ const { taskContext, taskId } = await resolveStopHookTaskContext({
72
+ TASK_FILE: `/tmp/stop-hook-nope-${Date.now()}.json`,
73
+ });
74
+ expect(taskId).toBeUndefined();
75
+ expect(taskContext).toBe("");
76
+ });
77
+
78
+ test("no env at all → both undefined/empty", async () => {
79
+ const { taskContext, taskId } = await resolveStopHookTaskContext({});
80
+ expect(taskId).toBeUndefined();
81
+ expect(taskContext).toBe("");
82
+ });
83
+
84
+ test("malformed TASK_FILE JSON does not throw, env var still wins", async () => {
85
+ const path = `/tmp/stop-hook-bad-${Date.now()}.json`;
86
+ await Bun.write(path, "not json {");
87
+ try {
88
+ const { taskContext, taskId } = await resolveStopHookTaskContext({
89
+ AGENT_SWARM_TASK_ID: "task-env-survives",
90
+ TASK_FILE: path,
91
+ });
92
+ expect(taskId).toBe("task-env-survives");
93
+ expect(taskContext).toBe("");
94
+ } finally {
95
+ await unlink(path).catch(() => {});
96
+ }
97
+ });
98
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Phase 3 — unit tests for `ui/src/lib/template-recommendations.ts`.
3
+ *
4
+ * Lives in `src/tests/` (not under `ui/`) because `ui/` has no test runner
5
+ * configured. The recommendation lib is pure logic with only a `StatusResponse`
6
+ * type import, so the cross-tree relative import works without aliases.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import type { StatusResponse } from "../../ui/src/api/types.ts";
11
+ import {
12
+ type DetectedIntegration,
13
+ detectedFromStatus,
14
+ recommendTemplates,
15
+ topRecommendation,
16
+ } from "../../ui/src/lib/template-recommendations.ts";
17
+
18
+ function makeStatus(overrides: {
19
+ slack?: "unverified" | "configured" | "verified";
20
+ github?: "unverified" | "configured" | "verified";
21
+ linear?: "unverified" | "configured" | "verified";
22
+ jira?: "unverified" | "configured" | "verified";
23
+ }): StatusResponse {
24
+ return {
25
+ identity: {
26
+ name: "Swarm",
27
+ logo_url: null,
28
+ brand_color: null,
29
+ is_cloud: false,
30
+ marketing_url: null,
31
+ hide_cloud_promo: false,
32
+ },
33
+ setup: [
34
+ { id: "harness", label: "Harness", state: "unverified" },
35
+ { id: "slack", label: "Slack", state: overrides.slack ?? "unverified" },
36
+ { id: "github", label: "GitHub", state: overrides.github ?? "unverified" },
37
+ { id: "linear", label: "Linear", state: overrides.linear ?? "unverified" },
38
+ { id: "jira", label: "Jira", state: overrides.jira ?? "unverified" },
39
+ { id: "workers", label: "Workers", state: "unverified" },
40
+ { id: "first_task", label: "First task", state: "unverified" },
41
+ ],
42
+ activity: { agents_online: 0, leads_online: 0, recent_tasks_count: 0 },
43
+ agent_fs: { configured: false, base_url: null },
44
+ health: "broken",
45
+ };
46
+ }
47
+
48
+ describe("recommendTemplates — priority rules", () => {
49
+ test("slack + github → pr-triage", () => {
50
+ const recs = recommendTemplates(new Set<DetectedIntegration>(["slack", "github"]));
51
+ expect(recs[0]?.templateId).toBe("pr-triage");
52
+ });
53
+
54
+ test("linear + github → issue-to-pr", () => {
55
+ const recs = recommendTemplates(new Set<DetectedIntegration>(["linear", "github"]));
56
+ expect(recs[0]?.templateId).toBe("issue-to-pr");
57
+ });
58
+
59
+ test("jira → bug-intake", () => {
60
+ const recs = recommendTemplates(new Set<DetectedIntegration>(["jira"]));
61
+ expect(recs[0]?.templateId).toBe("bug-intake");
62
+ });
63
+
64
+ test("empty set → hello-world fallback", () => {
65
+ const recs = recommendTemplates(new Set<DetectedIntegration>());
66
+ expect(recs).toHaveLength(1);
67
+ expect(recs[0]?.templateId).toBe("hello-world");
68
+ expect(recs[0]?.reason).toMatch(/hello world/i);
69
+ });
70
+
71
+ test("slack alone falls through to hello-world (no PR-triage promo without GitHub)", () => {
72
+ const recs = recommendTemplates(new Set<DetectedIntegration>(["slack"]));
73
+ expect(recs[0]?.templateId).toBe("hello-world");
74
+ });
75
+
76
+ test("github alone falls through to hello-world", () => {
77
+ const recs = recommendTemplates(new Set<DetectedIntegration>(["github"]));
78
+ expect(recs[0]?.templateId).toBe("hello-world");
79
+ });
80
+
81
+ test("linear alone falls through to hello-world", () => {
82
+ const recs = recommendTemplates(new Set<DetectedIntegration>(["linear"]));
83
+ expect(recs[0]?.templateId).toBe("hello-world");
84
+ });
85
+
86
+ test("priority — slack+github+linear matches pr-triage first, also matches issue-to-pr", () => {
87
+ const recs = recommendTemplates(new Set<DetectedIntegration>(["slack", "github", "linear"]));
88
+ // pr-triage comes first because slack+github rule is listed before linear+github.
89
+ expect(recs[0]?.templateId).toBe("pr-triage");
90
+ expect(recs.map((r) => r.templateId)).toContain("issue-to-pr");
91
+ });
92
+
93
+ test("all four detected — all three rule-based recs returned, no fallback", () => {
94
+ const recs = recommendTemplates(
95
+ new Set<DetectedIntegration>(["slack", "github", "linear", "jira"]),
96
+ );
97
+ const ids = recs.map((r) => r.templateId);
98
+ expect(ids).toEqual(["pr-triage", "issue-to-pr", "bug-intake"]);
99
+ expect(ids).not.toContain("hello-world");
100
+ });
101
+ });
102
+
103
+ describe("detectedFromStatus", () => {
104
+ test("verified milestones count as detected", () => {
105
+ const status = makeStatus({ slack: "verified", github: "verified" });
106
+ const detected = detectedFromStatus(status);
107
+ expect(detected.has("slack")).toBe(true);
108
+ expect(detected.has("github")).toBe(true);
109
+ });
110
+
111
+ test("configured milestones count as detected (live-call not required)", () => {
112
+ const status = makeStatus({ slack: "configured", jira: "configured" });
113
+ const detected = detectedFromStatus(status);
114
+ expect(detected.has("slack")).toBe(true);
115
+ expect(detected.has("jira")).toBe(true);
116
+ });
117
+
118
+ test("unverified milestones do NOT count as detected", () => {
119
+ const status = makeStatus({ slack: "unverified", github: "unverified" });
120
+ const detected = detectedFromStatus(status);
121
+ expect(detected.size).toBe(0);
122
+ });
123
+
124
+ test("non-integration milestones (harness, workers, first_task) are excluded", () => {
125
+ const status = makeStatus({});
126
+ // All four integration milestones are unverified by default; harness etc.
127
+ // are also unverified — none should leak into the detected set.
128
+ const detected = detectedFromStatus(status);
129
+ expect(detected.size).toBe(0);
130
+ });
131
+ });
132
+
133
+ describe("topRecommendation — end-to-end from a /status payload", () => {
134
+ test("slack+github verified → pr-triage", () => {
135
+ const status = makeStatus({ slack: "verified", github: "verified" });
136
+ expect(topRecommendation(status).templateId).toBe("pr-triage");
137
+ });
138
+
139
+ test("linear configured + github verified → issue-to-pr", () => {
140
+ const status = makeStatus({ linear: "configured", github: "verified" });
141
+ expect(topRecommendation(status).templateId).toBe("issue-to-pr");
142
+ });
143
+
144
+ test("nothing connected → hello-world", () => {
145
+ const status = makeStatus({});
146
+ expect(topRecommendation(status).templateId).toBe("hello-world");
147
+ });
148
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Phase 4 — pure-logic tests for `ui/src/hooks/use-dismissible-card.ts`.
3
+ *
4
+ * Lives in `src/tests/` (not under `ui/`) because:
5
+ * - `ui/` has no test runner configured (no vitest/jest).
6
+ * - The repo-root `bun test` already wires preload + DB fixtures.
7
+ * - We test the pure `deriveStorageKey()` helper plus localStorage-shape
8
+ * semantics by stubbing `globalThis.localStorage` — no React renderer.
9
+ *
10
+ * Hook semantics covered:
11
+ * - Namespace key derivation (format + uniqueness across apiUrls).
12
+ * - Dismiss / restore round-trip via the underlying localStorage shape.
13
+ * - Namespace isolation between two distinct apiUrls.
14
+ * - Graceful failure when `localStorage` throws.
15
+ *
16
+ * Cross-tab `storage` event handling lives in the React layer and is
17
+ * covered by the qa-use sessions in Success Criteria; pure-logic tests
18
+ * cannot exercise the `addEventListener("storage", …)` wiring meaningfully.
19
+ */
20
+
21
+ import { afterEach, describe, expect, test } from "bun:test";
22
+ // Import the pure helper directly — the parent `use-dismissible-card.ts`
23
+ // pulls in React + the `@/lib/config` alias chain via `useConfig`, which
24
+ // the bun-test runner can't resolve outside Vite.
25
+ import { deriveStorageKey } from "../../ui/src/hooks/use-dismissible-card-key.ts";
26
+
27
+ // Minimal in-memory localStorage shim for the round-trip / failure tests.
28
+ class MemoryStorage {
29
+ private store = new Map<string, string>();
30
+ private throwOnSet = false;
31
+
32
+ setThrowOnSet(value: boolean) {
33
+ this.throwOnSet = value;
34
+ }
35
+ getItem(key: string): string | null {
36
+ return this.store.has(key) ? (this.store.get(key) as string) : null;
37
+ }
38
+ setItem(key: string, value: string): void {
39
+ if (this.throwOnSet) throw new Error("QuotaExceededError (simulated)");
40
+ this.store.set(key, value);
41
+ }
42
+ removeItem(key: string): void {
43
+ this.store.delete(key);
44
+ }
45
+ clear(): void {
46
+ this.store.clear();
47
+ }
48
+ }
49
+
50
+ afterEach(() => {
51
+ // Clean up the global between tests so leakage can't mask bugs.
52
+ // biome-ignore lint/suspicious/noExplicitAny: test-only shim
53
+ delete (globalThis as any).localStorage;
54
+ });
55
+
56
+ describe("deriveStorageKey", () => {
57
+ test("namespaces by apiUrl + cardKey under swarm:v1 prefix", () => {
58
+ expect(deriveStorageKey("http://localhost:3013", "home-welcome")).toBe(
59
+ "swarm:v1:http://localhost:3013:home-welcome",
60
+ );
61
+ });
62
+
63
+ test("two distinct apiUrls produce distinct keys for the same cardKey", () => {
64
+ const a = deriveStorageKey("http://a.local:3013", "home-welcome");
65
+ const b = deriveStorageKey("http://b.local:3013", "home-welcome");
66
+ expect(a).not.toBe(b);
67
+ });
68
+
69
+ test("two distinct cardKeys produce distinct keys for the same apiUrl", () => {
70
+ const a = deriveStorageKey("http://localhost:3013", "home-welcome");
71
+ const b = deriveStorageKey("http://localhost:3013", "setup:row:harness");
72
+ expect(a).not.toBe(b);
73
+ });
74
+
75
+ test("structured cardKey separators (colons) survive the round-trip", () => {
76
+ expect(deriveStorageKey("http://x", "setup:tour-complete")).toBe(
77
+ "swarm:v1:http://x:setup:tour-complete",
78
+ );
79
+ });
80
+ });
81
+
82
+ describe("dismiss / restore round-trip via localStorage shape", () => {
83
+ test("dismiss writes '1' under the namespaced key; restore removes it", () => {
84
+ const storage = new MemoryStorage();
85
+ // biome-ignore lint/suspicious/noExplicitAny: test-only shim
86
+ (globalThis as any).localStorage = storage;
87
+
88
+ const key = deriveStorageKey("http://localhost:3013", "home-welcome");
89
+
90
+ // Initially undismissed.
91
+ expect(storage.getItem(key)).toBeNull();
92
+
93
+ // Simulate dismiss.
94
+ storage.setItem(key, "1");
95
+ expect(storage.getItem(key)).toBe("1");
96
+
97
+ // Simulate restore.
98
+ storage.removeItem(key);
99
+ expect(storage.getItem(key)).toBeNull();
100
+ });
101
+
102
+ test("namespace isolation: dismissing on apiUrl A does not affect apiUrl B", () => {
103
+ const storage = new MemoryStorage();
104
+ // biome-ignore lint/suspicious/noExplicitAny: test-only shim
105
+ (globalThis as any).localStorage = storage;
106
+
107
+ const keyA = deriveStorageKey("http://a.local:3013", "home-welcome");
108
+ const keyB = deriveStorageKey("http://b.local:3013", "home-welcome");
109
+
110
+ storage.setItem(keyA, "1");
111
+
112
+ expect(storage.getItem(keyA)).toBe("1");
113
+ expect(storage.getItem(keyB)).toBeNull();
114
+ });
115
+ });
116
+
117
+ describe("graceful failure when localStorage throws", () => {
118
+ test("setItem throw is swallowed by the hook's try/catch contract", () => {
119
+ const storage = new MemoryStorage();
120
+ storage.setThrowOnSet(true);
121
+ // biome-ignore lint/suspicious/noExplicitAny: test-only shim
122
+ (globalThis as any).localStorage = storage;
123
+
124
+ const key = deriveStorageKey("http://localhost:3013", "home-welcome");
125
+
126
+ // Direct call DOES throw — confirm the test shim is wired up.
127
+ expect(() => storage.setItem(key, "1")).toThrow();
128
+
129
+ // The hook contract is `try { localStorage.setItem(...) } catch {}` —
130
+ // emulate that wrapper and assert no error escapes to the caller.
131
+ const swallow = () => {
132
+ try {
133
+ storage.setItem(key, "1");
134
+ } catch {
135
+ // intentionally swallow
136
+ }
137
+ };
138
+ expect(swallow).not.toThrow();
139
+ });
140
+ });
@@ -1,7 +1,11 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { maskSecrets, upsertSwarmConfig } from "@/be/db";
4
- import { isReservedConfigKey, reservedKeyError } from "@/be/swarm-config-guard";
4
+ import {
5
+ isReservedConfigKey,
6
+ reservedKeyError,
7
+ validateConfigValue,
8
+ } from "@/be/swarm-config-guard";
5
9
  import { createToolRegistrar } from "@/tools/utils";
6
10
  import { SwarmConfigSchema, SwarmConfigScopeSchema } from "@/types";
7
11
 
@@ -89,6 +93,18 @@ export const registerSetConfigTool = (server: McpServer) => {
89
93
  };
90
94
  }
91
95
 
96
+ const validationError = validateConfigValue(key, value);
97
+ if (validationError) {
98
+ return {
99
+ content: [{ type: "text", text: validationError }],
100
+ structuredContent: {
101
+ yourAgentId: requestInfo.agentId,
102
+ success: false,
103
+ message: validationError,
104
+ },
105
+ };
106
+ }
107
+
92
108
  const config = upsertSwarmConfig({
93
109
  scope,
94
110
  scopeId: scope === "global" ? null : scopeId,
package/src/types.ts CHANGED
@@ -57,6 +57,7 @@ export const AgentTaskSourceSchema = z.enum([
57
57
  "mcp",
58
58
  "slack",
59
59
  "api",
60
+ "ui",
60
61
  "github",
61
62
  "gitlab",
62
63
  "agentmail",
@@ -233,6 +234,72 @@ export const UserSchema = z.object({
233
234
 
234
235
  export type User = z.infer<typeof UserSchema>;
235
236
 
237
+ // ============================================================================
238
+ // Inbox Item State (per-user dismiss/snooze/done for action-items inbox)
239
+ // ============================================================================
240
+ //
241
+ // Action-items inbox buckets:
242
+ // - approval — pending approval requests
243
+ // - credential_missing — agents in waiting_for_credentials state
244
+ // - broken_task — tasks in failed/cancelled status
245
+ // - to_read — sessions/tasks marked unread for the user
246
+ // - to_start_template — task-templates the user hasn't dismissed
247
+ //
248
+ // Statuses:
249
+ // - open — visible in inbox
250
+ // - snoozed — hidden until snoozeUntil; reappears as `open`
251
+ // - dismissed — hidden permanently (until item itself reactivates)
252
+ // - done — user marked complete
253
+ export const InboxItemTypeSchema = z.enum([
254
+ "approval",
255
+ "credential_missing",
256
+ "broken_task",
257
+ "to_read",
258
+ "to_start_template",
259
+ ]);
260
+ export type InboxItemType = z.infer<typeof InboxItemTypeSchema>;
261
+
262
+ export const InboxItemStatusSchema = z.enum(["open", "snoozed", "dismissed", "done"]);
263
+ export type InboxItemStatus = z.infer<typeof InboxItemStatusSchema>;
264
+
265
+ export const InboxItemStateSchema = z.object({
266
+ id: z.string(),
267
+ userId: z.string(),
268
+ itemType: InboxItemTypeSchema,
269
+ itemId: z.string(),
270
+ status: InboxItemStatusSchema,
271
+ snoozeUntil: z.string().optional(),
272
+ dismissedAt: z.string().optional(),
273
+ doneAt: z.string().optional(),
274
+ createdAt: z.string(),
275
+ lastUpdatedAt: z.string(),
276
+ });
277
+ export type InboxItemState = z.infer<typeof InboxItemStateSchema>;
278
+
279
+ // ============================================================================
280
+ // Task Templates ("To start" bucket — polymorphic starters registry)
281
+ // ============================================================================
282
+ //
283
+ // kind:
284
+ // - task — v1 default; payload is `{}` and the task prompt lives in `prompt`
285
+ // - workflow — v2 hook; payload `{ workflowId: string }`, prompt may be empty
286
+ // - schedule — v2 hook; payload `{ cron: string, prompt: string }`
287
+ export const TaskTemplateKindSchema = z.enum(["task", "workflow", "schedule"]);
288
+ export type TaskTemplateKind = z.infer<typeof TaskTemplateKindSchema>;
289
+
290
+ export const TaskTemplateSchema = z.object({
291
+ id: z.string(),
292
+ title: z.string().min(1),
293
+ description: z.string(),
294
+ prompt: z.string(),
295
+ kind: TaskTemplateKindSchema.default("task"),
296
+ payload: z.record(z.string(), z.unknown()).default({}),
297
+ category: z.string().optional(),
298
+ tags: z.array(z.string()).default([]),
299
+ createdAt: z.string(),
300
+ });
301
+ export type TaskTemplate = z.infer<typeof TaskTemplateSchema>;
302
+
236
303
  export const AgentStatusSchema = z.enum(["idle", "busy", "offline", "waiting_for_credentials"]);
237
304
 
238
305
  export const AgentSchema = z.object({
@@ -272,14 +339,64 @@ export const AgentSchema = z.object({
272
339
  // Harness provider this agent runs (claude, opencode, codex, ...)
273
340
  provider: ProviderNameSchema.optional(),
274
341
 
342
+ // Phase 1.5 (cloud-personalization): harness provider pushed by the worker
343
+ // on registration. Mirrors `provider` but lives in its own column so the
344
+ // server can answer "what harnesses are deployed?" without joining
345
+ // anywhere else, and so an operator can re-assign via
346
+ // PATCH /api/agents/:id/harness-provider without restarting the worker.
347
+ // Worker boot path is NOT yet rewritten (DES-359 tracks that) — the
348
+ // PATCH is a planning/forecast mechanism today; on next worker restart,
349
+ // the env-driven value wins.
350
+ harnessProvider: ProviderNameSchema.nullable().optional(),
351
+
275
352
  // Env-var names the worker is blocked on when status is
276
353
  // `waiting_for_credentials`. Null otherwise.
277
354
  credentialMissing: z.array(z.string()).nullable().optional(),
278
355
 
356
+ // Worker-self-reported credential snapshot for this agent's harness.
357
+ // Pairs with `harnessProvider`. Null = unreported (worker hasn't booted
358
+ // yet, or CRED_CHECK_DISABLE=1 was set). Migration 055 adds the column.
359
+ credStatus: z
360
+ .lazy(() => AgentCredStatusSchema)
361
+ .nullable()
362
+ .optional(),
363
+
279
364
  createdAt: z.iso.datetime().default(() => new Date().toISOString()),
280
365
  lastUpdatedAt: z.iso.datetime().default(() => new Date().toISOString()),
281
366
  });
282
367
 
368
+ // ---------------------------------------------------------------------------
369
+ // Worker-reported credential snapshot
370
+ // ---------------------------------------------------------------------------
371
+ // `provider` is intentionally absent from the JSON — already on the agent row
372
+ // as `harnessProvider`; the status endpoint joins them at read time.
373
+ //
374
+ // `reportKind` records the trigger that produced the report:
375
+ // - "boot": worker startup, full check (presence + live test).
376
+ // - "post_task": worker finished a task and `harness_provider` differed
377
+ // from its cached value, so it re-ran a full check (presence + live test).
378
+ //
379
+ // The cache-hit post-task path does NOT produce a new report; the row's
380
+ // `reportedAt` deliberately stays at the last actual check.
381
+ export const AgentCredStatusLiveTestSchema = z.object({
382
+ ok: z.boolean(),
383
+ error: z.string().nullable().default(null),
384
+ latency_ms: z.number(),
385
+ testedAt: z.number(), // unix ms
386
+ });
387
+ export type AgentCredStatusLiveTest = z.infer<typeof AgentCredStatusLiveTestSchema>;
388
+
389
+ export const AgentCredStatusSchema = z.object({
390
+ ready: z.boolean(),
391
+ missing: z.array(z.string()).default([]),
392
+ satisfiedBy: z.enum(["env", "file", "side-effect-pending"]).nullable().default(null),
393
+ hint: z.string().nullable().default(null),
394
+ liveTest: AgentCredStatusLiveTestSchema.nullable().default(null),
395
+ reportedAt: z.number(), // unix ms
396
+ reportKind: z.enum(["boot", "post_task"]).default("boot"),
397
+ });
398
+ export type AgentCredStatus = z.infer<typeof AgentCredStatusSchema>;
399
+
283
400
  export const AgentWithTasksSchema = AgentSchema.extend({
284
401
  tasks: z.array(AgentTaskSchema).default([]),
285
402
  });
@@ -0,0 +1,32 @@
1
+ import { type ProviderName, ProviderNameSchema } from "../types";
2
+
3
+ const SUPPORTED_PROVIDERS = ProviderNameSchema.options;
4
+
5
+ /**
6
+ * Resolve the effective `HARNESS_PROVIDER` for a worker.
7
+ *
8
+ * Precedence (highest first):
9
+ * 1. `resolvedEnv.HARNESS_PROVIDER` — value coming from `swarm_config`
10
+ * (overlay produced by `fetchResolvedEnv`, scoped repo > agent > global).
11
+ * 2. `fallbackEnv.HARNESS_PROVIDER` — raw `process.env`.
12
+ * 3. `"claude"` — final default.
13
+ *
14
+ * Invalid values (anything outside `ProviderNameSchema`) log a warning and
15
+ * fall back to `"claude"` rather than throwing — boot must not be killed
16
+ * by a typo'd swarm_config row.
17
+ */
18
+ export function resolveHarnessProvider(
19
+ resolvedEnv: Record<string, string | undefined>,
20
+ fallbackEnv: Record<string, string | undefined> = process.env,
21
+ ): ProviderName {
22
+ const candidate = resolvedEnv.HARNESS_PROVIDER?.trim() || fallbackEnv.HARNESS_PROVIDER?.trim();
23
+ if (!candidate) return "claude";
24
+ const parsed = ProviderNameSchema.safeParse(candidate);
25
+ if (!parsed.success) {
26
+ console.warn(
27
+ `[harness-provider] Invalid HARNESS_PROVIDER="${candidate}" (must be one of: ${SUPPORTED_PROVIDERS.join(", ")}); falling back to "claude"`,
28
+ );
29
+ return "claude";
30
+ }
31
+ return parsed.data;
32
+ }
package/tsconfig.json CHANGED
@@ -35,9 +35,7 @@
35
35
  },
36
36
  "exclude": [
37
37
  "ui",
38
- "new-ui",
39
38
  "templates-ui",
40
- "landing",
41
39
  "node_modules",
42
40
  "scripts",
43
41
  "src/tests",
@@ -1,74 +0,0 @@
1
- /**
2
- * Provider-agnostic credential check dispatcher.
3
- *
4
- * Used by:
5
- * - The worker boot loop (`src/commands/credential-wait.ts`) to decide
6
- * whether the worker can claim tasks yet.
7
- * - The dashboard credential-status endpoint, which surfaces the per-provider
8
- * `missing[]` list as a "blocked on …" hint.
9
- *
10
- * The predicate functions live alongside their adapters so they evolve
11
- * together; this module is a thin switch with documentation/UI hints
12
- * exported as a static map for the credential-status API.
13
- */
14
-
15
- import { checkClaudeCredentials } from "./claude-adapter";
16
- import { checkClaudeManagedCredentials } from "./claude-managed-adapter";
17
- import { checkCodexCredentials } from "./codex-adapter";
18
- import { checkDevinCredentials } from "./devin-adapter";
19
- import { checkOpencodeCredentials } from "./opencode-adapter";
20
- import { checkPiMonoCredentials } from "./pi-mono-adapter";
21
- import type { CredCheckOptions, CredStatus } from "./types";
22
-
23
- export type SupportedProvider = "claude" | "claude-managed" | "codex" | "devin" | "opencode" | "pi";
24
-
25
- /**
26
- * Static documentation of which env vars each provider considers when running
27
- * `checkCredentials`. Used by the dashboard to render hints before any worker
28
- * has reported its dynamic state. The arrays are illustrative — the real
29
- * authoritative answer always comes from the predicate function (which may
30
- * fold in `MODEL_OVERRIDE`-conditional logic for pi/opencode and file-based
31
- * fallbacks for codex/pi/opencode).
32
- */
33
- export const REQUIRED_CRED_VARS_BY_PROVIDER: Record<SupportedProvider, readonly string[]> = {
34
- claude: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
35
- "claude-managed": [
36
- "ANTHROPIC_API_KEY",
37
- "MANAGED_AGENT_ID",
38
- "MANAGED_ENVIRONMENT_ID",
39
- "MCP_BASE_URL",
40
- ],
41
- codex: ["OPENAI_API_KEY", "CODEX_OAUTH"],
42
- devin: ["DEVIN_API_KEY", "DEVIN_ORG_ID"],
43
- opencode: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
44
- pi: ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"],
45
- };
46
-
47
- /**
48
- * Run the predicate for `provider`. Unknown providers throw — call sites
49
- * should treat that as a configuration bug, not a user-correctable state.
50
- */
51
- export function checkProviderCredentials(
52
- provider: string,
53
- env: Record<string, string | undefined>,
54
- opts?: CredCheckOptions,
55
- ): CredStatus {
56
- switch (provider) {
57
- case "claude":
58
- return checkClaudeCredentials(env);
59
- case "claude-managed":
60
- return checkClaudeManagedCredentials(env);
61
- case "codex":
62
- return checkCodexCredentials(env, opts);
63
- case "devin":
64
- return checkDevinCredentials(env);
65
- case "opencode":
66
- return checkOpencodeCredentials(env, opts);
67
- case "pi":
68
- return checkPiMonoCredentials(env, opts);
69
- default:
70
- throw new Error(
71
- `checkProviderCredentials: unknown provider "${provider}". Supported: claude, claude-managed, codex, devin, opencode, pi.`,
72
- );
73
- }
74
- }