@desplega.ai/agent-swarm 1.92.2 → 1.93.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/openapi.json +63 -3
- package/package.json +5 -5
- package/src/be/db.ts +91 -6
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/modelsdev-cache.json +1222 -986
- package/src/be/seed-pricing.ts +1 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +5 -5
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +124 -7
- package/src/http/api-keys.ts +42 -0
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/tasks.ts +10 -6
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +7 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +11 -4
- package/src/providers/pi-mono-adapter.ts +12 -2
- package/src/providers/types.ts +2 -0
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +2 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/http-api-integration.test.ts +4 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/model-control.test.ts +1 -1
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tools/schedules/create-schedule.ts +2 -2
- package/src/tools/schedules/update-schedule.ts +1 -1
- package/src/tools/send-task.ts +2 -2
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +2 -2
- package/src/types.ts +11 -0
- package/src/utils/context-window.ts +3 -0
- package/src/utils/credentials.ts +22 -2
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
export const CONTEXT_FORMULA = "input-cache-output" as const;
|
|
27
27
|
|
|
28
28
|
const CONTEXT_WINDOW_DEFAULTS: Record<string, number> = {
|
|
29
|
+
// Anthropic Fable / Mythos tier
|
|
30
|
+
"claude-fable-5": 1_000_000,
|
|
29
31
|
// Anthropic 4.x family
|
|
30
32
|
"claude-opus-4-8": 1_000_000,
|
|
31
33
|
"claude-opus-4-7": 1_000_000,
|
|
@@ -45,6 +47,7 @@ const CONTEXT_WINDOW_DEFAULTS: Record<string, number> = {
|
|
|
45
47
|
"claude-3-sonnet": 200_000,
|
|
46
48
|
"claude-3-haiku": 200_000,
|
|
47
49
|
// Shortnames used by the local-CLI adapter and pi-mono OpenRouter mirror.
|
|
50
|
+
fable: 1_000_000,
|
|
48
51
|
opus: 1_000_000,
|
|
49
52
|
sonnet: 1_000_000,
|
|
50
53
|
haiku: 200_000,
|
package/src/utils/credentials.ts
CHANGED
|
@@ -64,6 +64,8 @@ export interface CredentialSelection {
|
|
|
64
64
|
keySuffix: string;
|
|
65
65
|
/** Which credential pool env var this selection came from */
|
|
66
66
|
keyType: string;
|
|
67
|
+
/** True when all indices for this keyType were rate-limited (best-effort pick) */
|
|
68
|
+
isRateLimitFallback: boolean;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/**
|
|
@@ -82,10 +84,19 @@ export function selectCredential(
|
|
|
82
84
|
.filter(Boolean);
|
|
83
85
|
if (credentials.length <= 1) {
|
|
84
86
|
const selected = value.trim();
|
|
85
|
-
|
|
87
|
+
const isRateLimitFallback = availableIndices !== undefined && availableIndices.length === 0;
|
|
88
|
+
return {
|
|
89
|
+
selected,
|
|
90
|
+
index: 0,
|
|
91
|
+
total: 1,
|
|
92
|
+
keySuffix: selected.slice(-5),
|
|
93
|
+
keyType,
|
|
94
|
+
isRateLimitFallback,
|
|
95
|
+
};
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
let index: number;
|
|
99
|
+
let isRateLimitFallback = false;
|
|
89
100
|
if (availableIndices && availableIndices.length > 0) {
|
|
90
101
|
// Pick randomly from available (non-rate-limited) indices
|
|
91
102
|
const validIndices = availableIndices.filter((i) => i >= 0 && i < credentials.length);
|
|
@@ -94,17 +105,26 @@ export function selectCredential(
|
|
|
94
105
|
} else {
|
|
95
106
|
// All available indices out of range — fall back to random from all
|
|
96
107
|
index = Math.floor(Math.random() * credentials.length);
|
|
108
|
+
isRateLimitFallback = true;
|
|
97
109
|
}
|
|
98
110
|
} else if (availableIndices && availableIndices.length === 0) {
|
|
99
111
|
// All keys are rate-limited — pick randomly anyway (best effort)
|
|
100
112
|
index = Math.floor(Math.random() * credentials.length);
|
|
113
|
+
isRateLimitFallback = true;
|
|
101
114
|
} else {
|
|
102
115
|
// No availability info — pure random (backward compatible)
|
|
103
116
|
index = Math.floor(Math.random() * credentials.length);
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
const selected = credentials[index]!;
|
|
107
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
selected,
|
|
122
|
+
index,
|
|
123
|
+
total: credentials.length,
|
|
124
|
+
keySuffix: selected.slice(-5),
|
|
125
|
+
keyType,
|
|
126
|
+
isRateLimitFallback,
|
|
127
|
+
};
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
/**
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, DB-free filesystem writer for agent skills.
|
|
3
|
+
*
|
|
4
|
+
* Worker-safe: imports only node:fs / node:os / node:path — no be/db, no bun:sqlite.
|
|
5
|
+
*
|
|
6
|
+
* Shared by:
|
|
7
|
+
* - API-side: syncSkillsToFilesystem (src/be/skill-sync.ts) which fetches
|
|
8
|
+
* SkillFsEntry data from the DB then delegates here.
|
|
9
|
+
* - Worker-side: refreshSkillsIfChanged (src/utils/skills-refresh.ts) which
|
|
10
|
+
* fetches SkillFsEntry data over HTTP then calls writeSkillsToFilesystem
|
|
11
|
+
* with the worker's own homedir(), writing SKILL.md files to the correct
|
|
12
|
+
* machine instead of the API box.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Dirent } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export interface SkillSyncResult {
|
|
20
|
+
synced: number;
|
|
21
|
+
removed: number;
|
|
22
|
+
errors: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SkillFsEntry {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
content: string | null;
|
|
29
|
+
isComplex: boolean;
|
|
30
|
+
isEnabled: boolean;
|
|
31
|
+
isActive: boolean;
|
|
32
|
+
files: { path: string; content: string; isBinary: boolean }[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Marker file written into every swarm-managed skill directory. Cleanup
|
|
37
|
+
* only ever removes directories that contain this marker, so unrelated
|
|
38
|
+
* personal skills the user installed via the harness's own tooling (e.g.
|
|
39
|
+
* `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
|
|
40
|
+
* untouched even when the API server shares a HOME with the worker (local
|
|
41
|
+
* dev). See `~/.codex/skills` blast-radius note in PR #555.
|
|
42
|
+
*/
|
|
43
|
+
export const SWARM_MARKER_FILE = ".swarm-managed";
|
|
44
|
+
|
|
45
|
+
function reconcileManagedSkillFiles(skillDir: string, currentRelativeFiles: Set<string>): number {
|
|
46
|
+
if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) return 0;
|
|
47
|
+
|
|
48
|
+
let removed = 0;
|
|
49
|
+
|
|
50
|
+
const walk = (dir: string, relativeDir = ""): boolean => {
|
|
51
|
+
let entries: Dirent[];
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let hasEntries = false;
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
|
61
|
+
const fullPath = join(dir, entry.name);
|
|
62
|
+
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
const childHasEntries = walk(fullPath, relativePath);
|
|
65
|
+
if (!childHasEntries) {
|
|
66
|
+
try {
|
|
67
|
+
rmSync(fullPath, { recursive: true, force: true });
|
|
68
|
+
} catch {
|
|
69
|
+
hasEntries = true;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
hasEntries = true;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
relativePath === "SKILL.md" ||
|
|
79
|
+
relativePath === SWARM_MARKER_FILE ||
|
|
80
|
+
currentRelativeFiles.has(relativePath)
|
|
81
|
+
) {
|
|
82
|
+
hasEntries = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
rmSync(fullPath, { force: true });
|
|
88
|
+
removed++;
|
|
89
|
+
} catch {
|
|
90
|
+
hasEntries = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return hasEntries;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
walk(skillDir);
|
|
98
|
+
return removed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write skill entries to the filesystem under the given home directory.
|
|
103
|
+
*
|
|
104
|
+
* For simple skills (non-complex): writes SKILL.md only.
|
|
105
|
+
* For DB-backed complex skills: writes SKILL.md plus bundled files.
|
|
106
|
+
* Skips legacy complex skills with no files (handled by npx in entrypoint).
|
|
107
|
+
* Binary files are skipped.
|
|
108
|
+
* Stale swarm-managed skill directories are cleaned up.
|
|
109
|
+
*/
|
|
110
|
+
export function writeSkillsToFilesystem(
|
|
111
|
+
entries: SkillFsEntry[],
|
|
112
|
+
harnessType: "claude" | "pi" | "codex" | "all" = "all",
|
|
113
|
+
home: string,
|
|
114
|
+
): SkillSyncResult {
|
|
115
|
+
const errors: string[] = [];
|
|
116
|
+
let synced = 0;
|
|
117
|
+
let removed = 0;
|
|
118
|
+
|
|
119
|
+
// Directories to write to
|
|
120
|
+
const skillDirs: string[] = [];
|
|
121
|
+
if (harnessType === "claude" || harnessType === "all") {
|
|
122
|
+
skillDirs.push(join(home, ".claude", "skills"));
|
|
123
|
+
}
|
|
124
|
+
if (harnessType === "pi" || harnessType === "all") {
|
|
125
|
+
skillDirs.push(join(home, ".pi", "agent", "skills"));
|
|
126
|
+
}
|
|
127
|
+
if (harnessType === "codex" || harnessType === "all") {
|
|
128
|
+
skillDirs.push(join(home, ".codex", "skills"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Ensure base dirs exist
|
|
132
|
+
for (const dir of skillDirs) {
|
|
133
|
+
mkdirSync(dir, { recursive: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Track which skill names we write (for cleanup)
|
|
137
|
+
const writtenNames = new Set<string>();
|
|
138
|
+
|
|
139
|
+
for (const skill of entries) {
|
|
140
|
+
if (!skill.isActive || !skill.isEnabled) continue;
|
|
141
|
+
if (skill.isComplex && skill.files.length === 0) continue; // Legacy complex skills handled by npx
|
|
142
|
+
if (!skill.content) continue;
|
|
143
|
+
|
|
144
|
+
// Sanitize skill name to prevent path traversal (strip /, .., and non-safe chars)
|
|
145
|
+
const safeName = skill.name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
146
|
+
if (!safeName) continue;
|
|
147
|
+
|
|
148
|
+
writtenNames.add(safeName);
|
|
149
|
+
const currentBundledFilePaths = new Set(
|
|
150
|
+
skill.files.filter((file) => !file.isBinary).map((file) => file.path),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
for (const baseDir of skillDirs) {
|
|
154
|
+
const skillDir = join(baseDir, safeName);
|
|
155
|
+
const skillFile = join(skillDir, "SKILL.md");
|
|
156
|
+
const markerFile = join(skillDir, SWARM_MARKER_FILE);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
mkdirSync(skillDir, { recursive: true });
|
|
160
|
+
removed += reconcileManagedSkillFiles(skillDir, currentBundledFilePaths);
|
|
161
|
+
writeFileSync(skillFile, skill.content, "utf-8");
|
|
162
|
+
writeFileSync(markerFile, "", "utf-8");
|
|
163
|
+
synced++;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
166
|
+
errors.push(`${skill.name} -> ${skillDir}: ${msg}`);
|
|
167
|
+
console.error(
|
|
168
|
+
`[skill-fs-writer] Failed to write SKILL.md for ${skill.name} to ${skillDir}: ${msg}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const file of skill.files) {
|
|
173
|
+
if (file.isBinary) {
|
|
174
|
+
console.log(`[skill-fs-writer] Skipping binary skill file ${skill.name}/${file.path}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const targetPath = join(skillDir, file.path);
|
|
179
|
+
try {
|
|
180
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
181
|
+
writeFileSync(targetPath, file.content, "utf-8");
|
|
182
|
+
} catch (err) {
|
|
183
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
184
|
+
errors.push(`${skill.name}/${file.path} -> ${targetPath}: ${msg}`);
|
|
185
|
+
console.error(
|
|
186
|
+
`[skill-fs-writer] Failed to write bundled file ${skill.name}/${file.path} to ${targetPath}: ${msg}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Cleanup: only remove directories WE previously created (marker file
|
|
194
|
+
// present). Leaves user-installed personal skills alone — important on
|
|
195
|
+
// local dev where ~/.codex/skills holds skills the user installed
|
|
196
|
+
// outside the swarm.
|
|
197
|
+
for (const baseDir of skillDirs) {
|
|
198
|
+
if (!existsSync(baseDir)) continue;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const existing = readdirSync(baseDir, { withFileTypes: true });
|
|
202
|
+
for (const entry of existing) {
|
|
203
|
+
if (!entry.isDirectory()) continue;
|
|
204
|
+
if (writtenNames.has(entry.name)) continue;
|
|
205
|
+
const skillDir = join(baseDir, entry.name);
|
|
206
|
+
if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
|
|
207
|
+
try {
|
|
208
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
209
|
+
removed++;
|
|
210
|
+
} catch {
|
|
211
|
+
// Non-fatal — skip cleanup errors
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Non-fatal — skip if we can't read the directory
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { synced, removed, errors };
|
|
220
|
+
}
|
|
@@ -2,15 +2,27 @@
|
|
|
2
2
|
* Worker-side per-task skill refresh.
|
|
3
3
|
*
|
|
4
4
|
* Polls the cheap signature endpoint; on a hash mismatch, refetches the
|
|
5
|
-
* full skill list and
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* full skill list and writes SKILL.md files to the worker's local HOME via
|
|
6
|
+
* writeSkillsToFilesystem() from skill-fs-writer.ts. This ensures newly
|
|
7
|
+
* created/approved skills land on the worker disk mid-session — no container
|
|
8
|
+
* restart required.
|
|
9
|
+
*
|
|
10
|
+
* Previously Step 3 POSTed to /api/skills/sync-filesystem, which wrote to
|
|
11
|
+
* the API server's HOME instead of the worker disk. Now Step 3 builds
|
|
12
|
+
* SkillFsEntry[] from the already-fetched skill data and writes locally.
|
|
13
|
+
* For complex skills the worker fetches bundled files via N+1 HTTP calls
|
|
14
|
+
* (acceptable for v1 — simple skills need zero extra fetches).
|
|
15
|
+
*
|
|
16
|
+
* The /api/skills/sync-filesystem endpoint is retained for single-box local
|
|
17
|
+
* dev (where API and worker share a HOME). Workers no longer call it.
|
|
9
18
|
*
|
|
10
19
|
* Transient errors are swallowed (returned as `changed: false`) so a flaky
|
|
11
20
|
* API can't churn the system prompt.
|
|
12
21
|
*/
|
|
13
22
|
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { type SkillFsEntry, writeSkillsToFilesystem } from "./skill-fs-writer";
|
|
25
|
+
|
|
14
26
|
export type SkillsRefreshContext = {
|
|
15
27
|
apiUrl: string;
|
|
16
28
|
swarmUrl: string;
|
|
@@ -27,6 +39,7 @@ export type SkillsRefreshResult = {
|
|
|
27
39
|
export async function refreshSkillsIfChanged(
|
|
28
40
|
ctx: SkillsRefreshContext,
|
|
29
41
|
lastHashRef: { current: string | null },
|
|
42
|
+
homeOverride?: string,
|
|
30
43
|
): Promise<SkillsRefreshResult> {
|
|
31
44
|
const { apiUrl, apiKey, agentId, role } = ctx;
|
|
32
45
|
const authHeaders: Record<string, string> = { "X-Agent-ID": agentId };
|
|
@@ -52,70 +65,140 @@ export async function refreshSkillsIfChanged(
|
|
|
52
65
|
return { changed: false };
|
|
53
66
|
}
|
|
54
67
|
|
|
55
|
-
// Step 2: full fetch
|
|
56
|
-
|
|
68
|
+
// Step 2: full fetch (only reached when hash differs or first call)
|
|
69
|
+
// Keep the full skill rows including content, id, isComplex — data is
|
|
70
|
+
// already on the wire, was previously discarded.
|
|
71
|
+
type SkillRow = {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
description: string;
|
|
75
|
+
content: string | null;
|
|
76
|
+
isComplex: boolean;
|
|
77
|
+
isEnabled: boolean;
|
|
78
|
+
isActive: boolean;
|
|
79
|
+
};
|
|
80
|
+
let skillRows: SkillRow[] = [];
|
|
57
81
|
let newHash: string | null = null;
|
|
82
|
+
let listFetchOk = false;
|
|
58
83
|
try {
|
|
59
84
|
const skillsResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills`, {
|
|
60
85
|
headers: authHeaders,
|
|
61
86
|
});
|
|
62
87
|
if (skillsResp.ok) {
|
|
63
88
|
const skillsData = (await skillsResp.json()) as {
|
|
64
|
-
skills:
|
|
89
|
+
skills: SkillRow[];
|
|
65
90
|
signature?: string;
|
|
66
91
|
};
|
|
67
|
-
|
|
68
|
-
.filter((s) => s.isActive && s.isEnabled)
|
|
69
|
-
.map((s) => ({ name: s.name, description: s.description }));
|
|
92
|
+
skillRows = skillsData.skills;
|
|
70
93
|
if (typeof skillsData.signature === "string") {
|
|
71
94
|
newHash = skillsData.signature;
|
|
72
95
|
}
|
|
96
|
+
listFetchOk = true;
|
|
73
97
|
}
|
|
74
98
|
} catch {
|
|
75
|
-
//
|
|
99
|
+
// Transient network / parse error — bail out without touching the local FS
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Guard: a failed list fetch must not proceed to writeSkillsToFilesystem.
|
|
103
|
+
// An empty entries array would wipe every swarm-managed skill directory from
|
|
104
|
+
// the worker disk, which is worse than leaving the cache stale.
|
|
105
|
+
if (!listFetchOk) {
|
|
106
|
+
return { changed: false };
|
|
76
107
|
}
|
|
77
108
|
|
|
78
|
-
|
|
109
|
+
const summary = skillRows
|
|
110
|
+
.filter((s) => s.isActive && s.isEnabled)
|
|
111
|
+
.map((s) => ({ name: s.name, description: s.description }));
|
|
112
|
+
|
|
113
|
+
// Step 3: build SkillFsEntry[] and write to THIS worker's local HOME.
|
|
114
|
+
//
|
|
115
|
+
// For complex+enabled skills, fetch bundled files via N+1 HTTP calls
|
|
116
|
+
// (GET /api/skills/:id/files for manifest, then per non-binary file).
|
|
117
|
+
// Simple skills (the common case) need zero extra fetches.
|
|
79
118
|
let syncOk = false;
|
|
80
119
|
try {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
120
|
+
const entries: SkillFsEntry[] = [];
|
|
121
|
+
|
|
122
|
+
for (const skill of skillRows) {
|
|
123
|
+
if (!skill.isActive || !skill.isEnabled) continue;
|
|
124
|
+
|
|
125
|
+
const files: { path: string; content: string; isBinary: boolean }[] = [];
|
|
126
|
+
|
|
127
|
+
if (skill.isComplex) {
|
|
128
|
+
// Fetch manifest to know which files exist + which are binary
|
|
129
|
+
try {
|
|
130
|
+
const manifestResp = await fetch(`${apiUrl}/api/skills/${skill.id}/files`, {
|
|
131
|
+
headers: authHeaders,
|
|
132
|
+
});
|
|
133
|
+
if (manifestResp.ok) {
|
|
134
|
+
const manifestData = (await manifestResp.json()) as {
|
|
135
|
+
files: { path: string; isBinary: boolean }[];
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Fetch content for each non-binary file (N+1 — acceptable for v1)
|
|
139
|
+
for (const manifestEntry of manifestData.files) {
|
|
140
|
+
if (manifestEntry.isBinary) {
|
|
141
|
+
files.push({ path: manifestEntry.path, content: "", isBinary: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const encodedPath = manifestEntry.path.split("/").map(encodeURIComponent).join("/");
|
|
146
|
+
const fileResp = await fetch(
|
|
147
|
+
`${apiUrl}/api/skills/${skill.id}/files/${encodedPath}`,
|
|
148
|
+
{ headers: authHeaders },
|
|
149
|
+
);
|
|
150
|
+
if (fileResp.ok) {
|
|
151
|
+
const fileData = (await fileResp.json()) as {
|
|
152
|
+
file: { path: string; content: string; isBinary: boolean };
|
|
153
|
+
};
|
|
154
|
+
files.push({
|
|
155
|
+
path: fileData.file.path,
|
|
156
|
+
content: fileData.file.content,
|
|
157
|
+
isBinary: fileData.file.isBinary,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Non-fatal — skip this file
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Non-fatal — treat as no files (will skip complex skill per writer logic)
|
|
167
|
+
}
|
|
101
168
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
169
|
+
|
|
170
|
+
entries.push({
|
|
171
|
+
id: skill.id,
|
|
172
|
+
name: skill.name,
|
|
173
|
+
content: skill.content ?? null,
|
|
174
|
+
isComplex: skill.isComplex,
|
|
175
|
+
isEnabled: skill.isEnabled,
|
|
176
|
+
isActive: skill.isActive,
|
|
177
|
+
files,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const writeResult = writeSkillsToFilesystem(entries, "all", homeOverride ?? homedir());
|
|
182
|
+
console.log(
|
|
183
|
+
`[${role}] Skills synced: ${writeResult.synced} written, ${writeResult.removed} removed`,
|
|
184
|
+
);
|
|
185
|
+
if (writeResult.errors.length > 0) {
|
|
186
|
+
console.warn(`[${role}] Skill sync errors: ${writeResult.errors.join(", ")}`);
|
|
105
187
|
}
|
|
188
|
+
syncOk = true;
|
|
106
189
|
} catch (err) {
|
|
107
190
|
console.warn(`[${role}] Skill sync failed: ${(err as Error).message}`);
|
|
108
191
|
}
|
|
109
192
|
|
|
110
|
-
if (
|
|
193
|
+
if (skillRows.length === 0 && newHash === null) {
|
|
111
194
|
return { changed: false };
|
|
112
195
|
}
|
|
113
196
|
|
|
114
|
-
// Only cache the new hash once the FS
|
|
115
|
-
// otherwise a transient
|
|
197
|
+
// Only cache the new hash once the local FS write has actually succeeded —
|
|
198
|
+
// otherwise a transient write failure would leave the cached hash matching
|
|
116
199
|
// the current signature, causing later polls to short-circuit and the
|
|
117
|
-
// disk state to stay stale
|
|
118
|
-
//
|
|
200
|
+
// disk state to stay stale forever. The next poll re-enters this code path
|
|
201
|
+
// (lastHashRef unchanged) and retries.
|
|
119
202
|
if (syncOk && newHash !== null) {
|
|
120
203
|
lastHashRef.current = newHash;
|
|
121
204
|
}
|