@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 +39 -3
- package/package.json +6 -6
- package/src/be/db.ts +2 -2
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/runner.ts +41 -54
- package/src/http/schedules.ts +34 -9
- package/src/http/skills.ts +27 -2
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/utils/skills-refresh.ts +123 -0
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.
|
|
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":
|
|
6932
|
+
"type": [
|
|
6933
|
+
"string",
|
|
6934
|
+
"null"
|
|
6935
|
+
]
|
|
6933
6936
|
},
|
|
6934
6937
|
"intervalMs": {
|
|
6935
|
-
"type":
|
|
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.
|
|
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.
|
|
112
|
-
"@earendil-works/pi-ai": "^0.75.
|
|
113
|
-
"@earendil-works/pi-coding-agent": "^0.75.
|
|
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.
|
|
116
|
-
"@opencode-ai/sdk": "^1.15.
|
|
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
|
+
}
|
package/src/be/skill-sync.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filesystem sync for skills.
|
|
3
3
|
*
|
|
4
|
-
* Writes installed skills to ~/.claude/skills/<name>/SKILL.md
|
|
5
|
-
* ~/.pi/agent/skills/<name>/SKILL.md
|
|
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" | "
|
|
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 === "
|
|
50
|
+
if (harnessType === "claude" || harnessType === "all") {
|
|
41
51
|
skillDirs.push(join(home, ".claude", "skills"));
|
|
42
52
|
}
|
|
43
|
-
if (harnessType === "pi" || harnessType === "
|
|
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
|
|
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()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}
|
package/src/commands/runner.ts
CHANGED
|
@@ -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
|
-
// ==========
|
|
3653
|
-
|
|
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
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
if (
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
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
|
-
|
|
3677
|
-
|
|
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
|
-
}
|
|
3680
|
-
console.
|
|
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 =
|
package/src/http/schedules.ts
CHANGED
|
@@ -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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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(
|
|
470
|
+
body.nextRunAt = calculateNextRun({
|
|
471
|
+
cronExpression: timing.mergedCron,
|
|
472
|
+
intervalMs: timing.mergedInterval,
|
|
473
|
+
timezone: mergedTimezone,
|
|
474
|
+
} as any);
|
|
450
475
|
}
|
|
451
476
|
}
|
|
452
477
|
}
|
package/src/http/skills.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|