@desplega.ai/agent-swarm 1.83.0 → 1.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.83.0",
5
+ "version": "1.83.1",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -6929,10 +6929,17 @@
6929
6929
  "type": "string"
6930
6930
  },
6931
6931
  "cronExpression": {
6932
- "type": "string"
6932
+ "type": [
6933
+ "string",
6934
+ "null"
6935
+ ]
6933
6936
  },
6934
6937
  "intervalMs": {
6935
- "type": "integer"
6938
+ "type": [
6939
+ "integer",
6940
+ "null"
6941
+ ],
6942
+ "exclusiveMinimum": 0
6936
6943
  },
6937
6944
  "taskTemplate": {
6938
6945
  "type": "string"
@@ -7896,6 +7903,35 @@
7896
7903
  }
7897
7904
  }
7898
7905
  },
7906
+ "/api/agents/{id}/skills/signature": {
7907
+ "get": {
7908
+ "summary": "Compute a stable signature over an agent's installed skills",
7909
+ "description": "Returns a sha256 hash over per-row mutation fields of the agent's active+enabled skill set. Workers poll this to detect skill changes cheaply without fetching the full list.",
7910
+ "tags": [
7911
+ "Skills"
7912
+ ],
7913
+ "security": [
7914
+ {
7915
+ "bearerAuth": []
7916
+ }
7917
+ ],
7918
+ "parameters": [
7919
+ {
7920
+ "schema": {
7921
+ "type": "string"
7922
+ },
7923
+ "required": true,
7924
+ "name": "id",
7925
+ "in": "path"
7926
+ }
7927
+ ],
7928
+ "responses": {
7929
+ "200": {
7930
+ "description": "Skills signature"
7931
+ }
7932
+ }
7933
+ }
7934
+ },
7899
7935
  "/api/scripts/upsert": {
7900
7936
  "post": {
7901
7937
  "operationId": "scripts_upsert",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.83.0",
3
+ "version": "1.83.1",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -108,12 +108,12 @@
108
108
  "@desplega.ai/localtunnel": "^2.2.0",
109
109
  "@inkjs/ui": "^2.0.0",
110
110
  "@linear/sdk": "^77.0.0",
111
- "@earendil-works/pi-agent-core": "^0.75.3",
112
- "@earendil-works/pi-ai": "^0.75.3",
113
- "@earendil-works/pi-coding-agent": "^0.75.3",
111
+ "@earendil-works/pi-agent-core": "^0.75.5",
112
+ "@earendil-works/pi-ai": "^0.75.5",
113
+ "@earendil-works/pi-coding-agent": "^0.75.5",
114
114
  "@modelcontextprotocol/sdk": "^1.25.1",
115
- "@openai/codex-sdk": "^0.130.0",
116
- "@opencode-ai/sdk": "^1.15.4",
115
+ "@openai/codex-sdk": "^0.133.0",
116
+ "@opencode-ai/sdk": "^1.15.10",
117
117
  "@openfort/openfort-node": "^0.9.1",
118
118
  "@opentelemetry/api": "^1.9.1",
119
119
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
package/src/be/db.ts CHANGED
@@ -4906,8 +4906,8 @@ export function createScheduledTask(data: CreateScheduledTaskData): ScheduledTas
4906
4906
  export interface UpdateScheduledTaskData {
4907
4907
  name?: string;
4908
4908
  description?: string;
4909
- cronExpression?: string;
4910
- intervalMs?: number;
4909
+ cronExpression?: string | null;
4910
+ intervalMs?: number | null;
4911
4911
  taskTemplate?: string;
4912
4912
  taskType?: string;
4913
4913
  tags?: string[];
@@ -0,0 +1,21 @@
1
+ export type MergedScheduleTiming = { mergedCron: string | null; mergedInterval: number | null };
2
+ export type ScheduleTimingPatch = { cronExpression?: string | null; intervalMs?: number | null };
3
+
4
+ export function mergeScheduleTiming(
5
+ existing: { cronExpression: string | null; intervalMs: number | null },
6
+ patch: ScheduleTimingPatch,
7
+ ): MergedScheduleTiming {
8
+ return {
9
+ mergedCron: patch.cronExpression !== undefined ? patch.cronExpression : existing.cronExpression,
10
+ mergedInterval: patch.intervalMs !== undefined ? patch.intervalMs : existing.intervalMs,
11
+ };
12
+ }
13
+
14
+ export type RecurringTimingError = { kind: "both-null" } | null;
15
+
16
+ export function validateRecurringTiming(merged: MergedScheduleTiming): RecurringTimingError {
17
+ if (merged.mergedCron === null && merged.mergedInterval === null) {
18
+ return { kind: "both-null" };
19
+ }
20
+ return null;
21
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Filesystem sync for skills.
3
3
  *
4
- * Writes installed skills to ~/.claude/skills/<name>/SKILL.md (and optionally
5
- * ~/.pi/agent/skills/<name>/SKILL.md) so Claude Code and Pi discover them
6
- * natively.
4
+ * Writes installed skills to ~/.claude/skills/<name>/SKILL.md,
5
+ * ~/.pi/agent/skills/<name>/SKILL.md, and ~/.codex/skills/<name>/SKILL.md
6
+ * so Claude Code, Pi, and Codex discover them natively.
7
7
  *
8
8
  * This runs on the API side — workers call it via POST /api/skills/sync-filesystem.
9
9
  */
@@ -19,6 +19,16 @@ export interface SkillSyncResult {
19
19
  errors: string[];
20
20
  }
21
21
 
22
+ /**
23
+ * Marker file written into every swarm-managed skill directory. Cleanup
24
+ * only ever removes directories that contain this marker, so unrelated
25
+ * personal skills the user installed via the harness's own tooling (e.g.
26
+ * `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
27
+ * untouched even when the API server shares a HOME with the worker (local
28
+ * dev). See `~/.codex/skills` blast-radius note in PR #555.
29
+ */
30
+ const SWARM_MARKER_FILE = ".swarm-managed";
31
+
22
32
  /**
23
33
  * Sync agent's installed skills to the filesystem.
24
34
  *
@@ -27,7 +37,7 @@ export interface SkillSyncResult {
27
37
  */
28
38
  export function syncSkillsToFilesystem(
29
39
  agentId: string,
30
- harnessType: "claude" | "pi" | "both" = "both",
40
+ harnessType: "claude" | "pi" | "codex" | "all" = "all",
31
41
  homeOverride?: string,
32
42
  ): SkillSyncResult {
33
43
  const skills = getAgentSkills(agentId);
@@ -37,12 +47,15 @@ export function syncSkillsToFilesystem(
37
47
 
38
48
  // Directories to write to
39
49
  const skillDirs: string[] = [];
40
- if (harnessType === "claude" || harnessType === "both") {
50
+ if (harnessType === "claude" || harnessType === "all") {
41
51
  skillDirs.push(join(home, ".claude", "skills"));
42
52
  }
43
- if (harnessType === "pi" || harnessType === "both") {
53
+ if (harnessType === "pi" || harnessType === "all") {
44
54
  skillDirs.push(join(home, ".pi", "agent", "skills"));
45
55
  }
56
+ if (harnessType === "codex" || harnessType === "all") {
57
+ skillDirs.push(join(home, ".codex", "skills"));
58
+ }
46
59
 
47
60
  // Ensure base dirs exist
48
61
  for (const dir of skillDirs) {
@@ -66,10 +79,12 @@ export function syncSkillsToFilesystem(
66
79
  for (const baseDir of skillDirs) {
67
80
  const skillDir = join(baseDir, safeName);
68
81
  const skillFile = join(skillDir, "SKILL.md");
82
+ const markerFile = join(skillDir, SWARM_MARKER_FILE);
69
83
 
70
84
  try {
71
85
  mkdirSync(skillDir, { recursive: true });
72
86
  writeFileSync(skillFile, skill.content, "utf-8");
87
+ writeFileSync(markerFile, "", "utf-8");
73
88
  synced++;
74
89
  } catch (err) {
75
90
  errors.push(
@@ -79,7 +94,10 @@ export function syncSkillsToFilesystem(
79
94
  }
80
95
  }
81
96
 
82
- // Cleanup: remove skill directories that are no longer installed
97
+ // Cleanup: only remove directories WE previously created (marker file
98
+ // present). Leaves user-installed personal skills alone — important on
99
+ // local dev where ~/.codex/skills holds skills the user installed
100
+ // outside the swarm.
83
101
  let removed = 0;
84
102
  for (const baseDir of skillDirs) {
85
103
  if (!existsSync(baseDir)) continue;
@@ -87,14 +105,15 @@ export function syncSkillsToFilesystem(
87
105
  try {
88
106
  const existing = readdirSync(baseDir, { withFileTypes: true });
89
107
  for (const entry of existing) {
90
- if (entry.isDirectory() && !writtenNames.has(entry.name)) {
91
- const skillDir = join(baseDir, entry.name);
92
- try {
93
- rmSync(skillDir, { recursive: true, force: true });
94
- removed++;
95
- } catch {
96
- // Non-fatal — skip cleanup errors
97
- }
108
+ if (!entry.isDirectory()) continue;
109
+ if (writtenNames.has(entry.name)) continue;
110
+ const skillDir = join(baseDir, entry.name);
111
+ if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
112
+ try {
113
+ rmSync(skillDir, { recursive: true, force: true });
114
+ removed++;
115
+ } catch {
116
+ // Non-fatal — skip cleanup errors
98
117
  }
99
118
  }
100
119
  } catch {
@@ -104,3 +123,34 @@ export function syncSkillsToFilesystem(
104
123
 
105
124
  return { synced, removed, errors };
106
125
  }
126
+
127
+ export interface SkillsSignature {
128
+ hash: string;
129
+ count: number;
130
+ }
131
+
132
+ /**
133
+ * Compute a stable signature over an agent's installed-and-enabled skill set.
134
+ *
135
+ * Hash inputs are the per-row mutation-tracking fields — any install,
136
+ * uninstall, toggle, or skill-update mutates at least one of them. Output is
137
+ * deterministic and contains no timestamps beyond per-row mutation fields.
138
+ */
139
+ export function computeAgentSkillsSignature(agentId: string): SkillsSignature {
140
+ const skills = getAgentSkills(agentId);
141
+ const sorted = [...skills].sort((a, b) => a.id.localeCompare(b.id));
142
+ const canonical = JSON.stringify(
143
+ sorted.map((s) => [
144
+ s.id,
145
+ s.name,
146
+ s.version,
147
+ s.isEnabled,
148
+ s.isActive,
149
+ s.lastUpdatedAt,
150
+ s.sourceHash ?? "",
151
+ s.installedAt,
152
+ ]),
153
+ );
154
+ const hash = new Bun.CryptoHasher("sha256").update(canonical).digest("hex");
155
+ return { hash, count: sorted.length };
156
+ }
@@ -42,6 +42,7 @@ import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
42
42
  import { resolveHarnessProvider } from "../utils/harness-provider.ts";
43
43
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
44
44
  import { scrubSecrets } from "../utils/secret-scrubber.ts";
45
+ import { refreshSkillsIfChanged } from "../utils/skills-refresh.ts";
45
46
  import { detectVcsProvider } from "../vcs/index.ts";
46
47
  import { interpolate } from "../workflows/template.ts";
47
48
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
@@ -3511,34 +3512,6 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3511
3512
  }
3512
3513
  }
3513
3514
 
3514
- // Fetch installed skills for system prompt
3515
- try {
3516
- const skillsResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills`, {
3517
- headers: {
3518
- Authorization: `Bearer ${apiKey}`,
3519
- "X-Agent-ID": agentId,
3520
- },
3521
- });
3522
- if (skillsResp.ok) {
3523
- const skillsData = (await skillsResp.json()) as {
3524
- skills: {
3525
- name: string;
3526
- description: string;
3527
- isActive: boolean;
3528
- isEnabled: boolean;
3529
- }[];
3530
- };
3531
- agentSkillsSummary = skillsData.skills
3532
- .filter((s) => s.isActive && s.isEnabled)
3533
- .map((s) => ({ name: s.name, description: s.description }));
3534
- if (agentSkillsSummary.length > 0) {
3535
- console.log(`[${role}] Loaded ${agentSkillsSummary.length} skills for system prompt`);
3536
- }
3537
- }
3538
- } catch {
3539
- // Non-fatal — skills are optional
3540
- }
3541
-
3542
3515
  // Fetch installed MCP servers for system prompt
3543
3516
  try {
3544
3517
  const mcpServersResp = await fetch(`${apiUrl}/api/agents/${agentId}/mcp-servers`, {
@@ -3649,35 +3622,32 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3649
3622
  }
3650
3623
  }
3651
3624
 
3652
- // ========== Sync skills to filesystem ==========
3653
- try {
3625
+ // ========== Boot-time skill load (signature-gated, replaces the standalone
3626
+ // skill-fetch + FS sync blocks). The polling loop below calls the same
3627
+ // helper per task to hot-reload skills mid-flight. Skipped for
3628
+ // `claude-managed` (cloud sandbox owns skill delivery).
3629
+ const lastSkillHash: { current: string | null } = { current: null };
3630
+ if (state.harnessProvider !== "claude-managed") {
3654
3631
  console.log(`[${role}] Syncing skills to filesystem...`);
3655
- const syncHeaders: Record<string, string> = {
3656
- "Content-Type": "application/json",
3657
- "X-Agent-ID": agentId,
3658
- };
3659
- if (apiKey) syncHeaders.Authorization = `Bearer ${apiKey}`;
3660
- const syncRes = await fetch(`${swarmUrl}/api/skills/sync-filesystem`, {
3661
- method: "POST",
3662
- headers: syncHeaders,
3663
- });
3664
- if (syncRes.ok) {
3665
- const syncResult = (await syncRes.json()) as {
3666
- synced: number;
3667
- removed: number;
3668
- errors: string[];
3669
- };
3670
- console.log(
3671
- `[${role}] Skills synced: ${syncResult.synced} written, ${syncResult.removed} removed`,
3672
- );
3673
- if (syncResult.errors.length > 0) {
3674
- console.warn(`[${role}] Skill sync errors: ${syncResult.errors.join(", ")}`);
3632
+ const skillResult = await refreshSkillsIfChanged(
3633
+ { apiUrl, swarmUrl, apiKey, agentId, role },
3634
+ lastSkillHash,
3635
+ );
3636
+ if (skillResult.changed && skillResult.summary) {
3637
+ agentSkillsSummary = skillResult.summary;
3638
+ if (agentSkillsSummary.length > 0) {
3639
+ console.log(`[${role}] Loaded ${agentSkillsSummary.length} skills for system prompt`);
3675
3640
  }
3676
- } else {
3677
- console.warn(`[${role}] Skill sync failed: HTTP ${syncRes.status}`);
3641
+ // Rebuild base prompt now that we have skills.
3642
+ basePrompt = await buildSystemPrompt();
3643
+ resolvedSystemPrompt = additionalSystemPrompt
3644
+ ? `${basePrompt}\n\n${additionalSystemPrompt}`
3645
+ : basePrompt;
3678
3646
  }
3679
- } catch (err) {
3680
- console.warn(`[${role}] Skill sync failed: ${(err as Error).message}`);
3647
+ } else {
3648
+ console.log(
3649
+ `[${role}] Skipping skill sync (claude-managed reads skills from agent definition)`,
3650
+ );
3681
3651
  }
3682
3652
 
3683
3653
  // ========== Resume paused tasks with PRIORITY ==========
@@ -4168,6 +4138,23 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4168
4138
  cwdWarning = `\n\nNote: The task requested working directory "${taskDir}" but it does not exist. Falling back to default directory.`;
4169
4139
  }
4170
4140
 
4141
+ // Per-task skill hot-reload. Reuses the boot-time helper; signature
4142
+ // probe short-circuits when nothing changed. Skipped for
4143
+ // `claude-managed`. Read state.harnessProvider live so an adapter
4144
+ // swap mid-loop honors the new provider.
4145
+ if (state.harnessProvider !== "claude-managed") {
4146
+ const skillResult = await refreshSkillsIfChanged(
4147
+ { apiUrl, swarmUrl, apiKey, agentId, role },
4148
+ lastSkillHash,
4149
+ );
4150
+ if (skillResult.changed && skillResult.summary) {
4151
+ agentSkillsSummary = skillResult.summary;
4152
+ console.log(
4153
+ `[${role}] Skills changed — refreshing system prompt (${agentSkillsSummary.length} skills)`,
4154
+ );
4155
+ }
4156
+ }
4157
+
4171
4158
  // Rebuild system prompt with per-task repo context
4172
4159
  const taskBasePrompt = await buildSystemPrompt();
4173
4160
  const taskSystemPrompt =
@@ -11,6 +11,7 @@ import {
11
11
  getScheduledTasks,
12
12
  updateScheduledTask,
13
13
  } from "../be/db";
14
+ import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
14
15
  import { calculateNextRun } from "../scheduler/scheduler";
15
16
  import { scheduleContextKey } from "../tasks/context-key";
16
17
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
@@ -115,8 +116,8 @@ const updateSchedule = route({
115
116
  body: z.object({
116
117
  name: z.string().optional(),
117
118
  description: z.string().optional(),
118
- cronExpression: z.string().optional(),
119
- intervalMs: z.number().int().optional(),
119
+ cronExpression: z.string().nullable().optional(),
120
+ intervalMs: z.number().int().positive().nullable().optional(),
120
121
  taskTemplate: z.string().optional(),
121
122
  taskType: z.string().optional(),
122
123
  tags: z.array(z.string()).optional(),
@@ -400,6 +401,22 @@ export async function handleSchedules(
400
401
  return true;
401
402
  }
402
403
 
404
+ // Validate merged timing state — catches cases where one side is null in the DB
405
+ // and the patch nulls the other, which the schema-level check cannot see.
406
+ if (existing.scheduleType !== "one_time") {
407
+ const timing = mergeScheduleTiming(
408
+ {
409
+ cronExpression: existing.cronExpression ?? null,
410
+ intervalMs: existing.intervalMs ?? null,
411
+ },
412
+ { cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
413
+ );
414
+ if (validateRecurringTiming(timing)) {
415
+ jsonError(res, "At least one of intervalMs or cronExpression must be set", 400);
416
+ return true;
417
+ }
418
+ }
419
+
403
420
  if (parsed.body.cronExpression) {
404
421
  try {
405
422
  CronExpressionParser.parse(parsed.body.cronExpression);
@@ -439,14 +456,22 @@ export async function handleSchedules(
439
456
  parsed.body.intervalMs !== undefined ||
440
457
  (parsed.body.enabled === true && !existing.enabled)
441
458
  ) {
442
- const merged = {
443
- cronExpression: parsed.body.cronExpression ?? existing.cronExpression,
444
- intervalMs: parsed.body.intervalMs ?? existing.intervalMs,
445
- timezone: parsed.body.timezone ?? existing.timezone,
446
- };
447
- if (merged.cronExpression || merged.intervalMs) {
459
+ const timing = mergeScheduleTiming(
460
+ {
461
+ cronExpression: existing.cronExpression ?? null,
462
+ intervalMs: existing.intervalMs ?? null,
463
+ },
464
+ { cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
465
+ );
466
+ const mergedTimezone =
467
+ parsed.body.timezone !== undefined ? parsed.body.timezone : existing.timezone;
468
+ if (timing.mergedCron || timing.mergedInterval) {
448
469
  // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
449
- body.nextRunAt = calculateNextRun(merged as any);
470
+ body.nextRunAt = calculateNextRun({
471
+ cronExpression: timing.mergedCron,
472
+ intervalMs: timing.mergedInterval,
473
+ timezone: mergedTimezone,
474
+ } as any);
450
475
  }
451
476
  }
452
477
  }
@@ -12,7 +12,7 @@ import {
12
12
  updateSkill,
13
13
  } from "../be/db";
14
14
  import { parseSkillContent } from "../be/skill-parser";
15
- import { syncSkillsToFilesystem } from "../be/skill-sync";
15
+ import { computeAgentSkillsSignature, syncSkillsToFilesystem } from "../be/skill-sync";
16
16
  import { route } from "./route-def";
17
17
  import { json, jsonError } from "./utils";
18
18
 
@@ -193,6 +193,21 @@ const getAgentSkillsRoute = route({
193
193
  },
194
194
  });
195
195
 
196
+ const getAgentSkillsSignatureRoute = route({
197
+ method: "get",
198
+ path: "/api/agents/{id}/skills/signature",
199
+ pattern: ["api", "agents", null, "skills", "signature"],
200
+ summary: "Compute a stable signature over an agent's installed skills",
201
+ description:
202
+ "Returns a sha256 hash over per-row mutation fields of the agent's active+enabled skill set. Workers poll this to detect skill changes cheaply without fetching the full list.",
203
+ tags: ["Skills"],
204
+ auth: { apiKey: true },
205
+ params: z.object({ id: z.string() }),
206
+ responses: {
207
+ 200: { description: "Skills signature" },
208
+ },
209
+ });
210
+
196
211
  // ─── Handler ─────────────────────────────────────────────────────────────────
197
212
 
198
213
  export async function handleSkills(
@@ -202,12 +217,22 @@ export async function handleSkills(
202
217
  queryParams: URLSearchParams,
203
218
  myAgentId: string | undefined,
204
219
  ): Promise<boolean> {
220
+ // GET /api/agents/:id/skills/signature (must come before the shorter pattern)
221
+ if (getAgentSkillsSignatureRoute.match(req.method, pathSegments)) {
222
+ const parsed = await getAgentSkillsSignatureRoute.parse(req, res, pathSegments, queryParams);
223
+ if (!parsed) return true;
224
+ const sig = computeAgentSkillsSignature(parsed.params.id);
225
+ json(res, { hash: sig.hash, count: sig.count, generatedAt: new Date().toISOString() });
226
+ return true;
227
+ }
228
+
205
229
  // GET /api/agents/:id/skills (must be before /api/skills routes)
206
230
  if (getAgentSkillsRoute.match(req.method, pathSegments)) {
207
231
  const parsed = await getAgentSkillsRoute.parse(req, res, pathSegments, queryParams);
208
232
  if (!parsed) return true;
209
233
  const skills = getAgentSkills(parsed.params.id);
210
- json(res, { skills, total: skills.length });
234
+ const signature = computeAgentSkillsSignature(parsed.params.id).hash;
235
+ json(res, { skills, total: skills.length, signature });
211
236
  return true;
212
237
  }
213
238
 
@@ -968,6 +968,42 @@ describe("Schedule CRUD", () => {
968
968
  expect(status).toBe(404);
969
969
  });
970
970
 
971
+ test("PUT /api/schedules/:id — cron-based schedule accepts null intervalMs", async () => {
972
+ // Reproduces CAI-1283: dashboard sends null for unused timing field
973
+ const { status, body } = await put(`/api/schedules/${scheduleId}`, {
974
+ body: { cronExpression: "0 2 * * *", intervalMs: null },
975
+ });
976
+ expect(status).toBe(200);
977
+ expect(body.cronExpression).toBe("0 2 * * *");
978
+ // intervalMs omitted from response when not set (null serialized as undefined in JSON)
979
+ expect(body.intervalMs == null).toBe(true);
980
+ });
981
+
982
+ test("PUT /api/schedules/:id — interval-based schedule accepts null cronExpression", async () => {
983
+ // Create an interval-based schedule to test against
984
+ const { body: created } = await post("/api/schedules", {
985
+ body: {
986
+ name: "interval-test-cai-1283",
987
+ taskTemplate: "interval task",
988
+ intervalMs: 60000,
989
+ },
990
+ });
991
+ const { status, body } = await put(`/api/schedules/${created.id}`, {
992
+ body: { intervalMs: 60000, cronExpression: null },
993
+ });
994
+ expect(status).toBe(200);
995
+ expect(body.intervalMs).toBe(60000);
996
+ // Clean up
997
+ await del(`/api/schedules/${created.id}`);
998
+ });
999
+
1000
+ test("PUT /api/schedules/:id — both intervalMs and cronExpression null returns 400", async () => {
1001
+ const { status } = await put(`/api/schedules/${scheduleId}`, {
1002
+ body: { cronExpression: null, intervalMs: null },
1003
+ });
1004
+ expect(status).toBe(400);
1005
+ });
1006
+
971
1007
  test("POST /api/schedules/:id/run — run now creates a task", async () => {
972
1008
  const { status, body } = await post(`/api/schedules/${scheduleId}/run`);
973
1009
  expect(status).toBe(200);