@desplega.ai/agent-swarm 1.90.0 → 1.92.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/README.md +2 -1
- package/openapi.json +803 -150
- package/package.json +5 -5
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +337 -1
- package/src/be/memory/providers/sqlite-store.ts +6 -1
- package/src/be/memory/types.ts +1 -0
- package/src/be/migrations/083_script_workflows.sql +51 -0
- package/src/be/modelsdev-cache.json +42352 -38595
- package/src/be/scripts/typecheck.ts +181 -1
- package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
- package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
- package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
- package/src/be/seed-scripts/index.ts +54 -0
- package/src/be/seed-skills/index.ts +7 -0
- package/src/be/swarm-config-guard.ts +17 -0
- package/src/commands/artifact.ts +3 -2
- package/src/commands/profile-sync.ts +310 -0
- package/src/commands/runner.ts +134 -3
- package/src/hooks/hook.ts +32 -9
- package/src/http/db-query.ts +20 -5
- package/src/http/index.ts +57 -0
- package/src/http/integrations.ts +6 -1
- package/src/http/mcp-bridge.ts +117 -0
- package/src/http/mcp-oauth.ts +97 -39
- package/src/http/memory.ts +5 -2
- package/src/http/openapi.ts +2 -2
- package/src/http/pages-public.ts +10 -11
- package/src/http/pages.ts +7 -11
- package/src/http/script-runs.ts +555 -0
- package/src/http/scripts.ts +24 -1
- package/src/http/utils.ts +11 -4
- package/src/jira/app.ts +2 -3
- package/src/jira/webhook-lifecycle.ts +2 -1
- package/src/linear/app.ts +2 -3
- package/src/prompts/session-templates.ts +24 -4
- package/src/providers/claude-adapter.ts +86 -13
- package/src/script-workflows/executor.ts +110 -0
- package/src/script-workflows/harness.ts +73 -0
- package/src/script-workflows/label-lint.ts +51 -0
- package/src/script-workflows/limits.ts +22 -0
- package/src/script-workflows/supervisor.ts +139 -0
- package/src/script-workflows/workflow-ctx.ts +205 -0
- package/src/scripts-runtime/executors/native.ts +1 -0
- package/src/scripts-runtime/sdk-allowlist.ts +124 -0
- package/src/scripts-runtime/swarm-sdk.ts +198 -3
- package/src/scripts-runtime/types/stdlib.d.ts +287 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
- package/src/server.ts +2 -0
- package/src/slack/handlers.ts +11 -4
- package/src/slack/message-text.ts +98 -0
- package/src/slack/thread-buffer.ts +5 -3
- package/src/tests/claude-adapter-binary.test.ts +147 -4
- package/src/tests/claude-adapter-otel.test.ts +85 -1
- package/src/tests/db-query.test.ts +28 -0
- package/src/tests/error-tracker.test.ts +121 -0
- package/src/tests/harness-provider-resolution.test.ts +33 -0
- package/src/tests/hook-registration-nudge.test.ts +69 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
- package/src/tests/mcp-tools.test.ts +6 -0
- package/src/tests/pages-public-html.test.ts +41 -0
- package/src/tests/pages-public-json-redirect.test.ts +37 -2
- package/src/tests/profile-sync.test.ts +282 -0
- package/src/tests/prompt-template-session.test.ts +34 -5
- package/src/tests/script-runs-http.test.ts +278 -0
- package/src/tests/script-workflows-label-lint.test.ts +43 -0
- package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
- package/src/tests/scripts-mcp-e2e.test.ts +49 -2
- package/src/tests/scripts-runtime.test.ts +33 -0
- package/src/tests/seed-scripts.test.ts +347 -2
- package/src/tests/slack-message-text.test.ts +250 -0
- package/src/tests/system-default-skills.test.ts +40 -0
- package/src/tools/create-metric.ts +2 -3
- package/src/tools/create-page.ts +3 -6
- package/src/tools/db-query.ts +16 -6
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/register-kapso-number.ts +2 -4
- package/src/tools/request-human-input.ts +2 -1
- package/src/tools/script-common.ts +2 -4
- package/src/tools/script-run.ts +7 -0
- package/src/tools/script-runs.ts +123 -0
- package/src/tools/slack-read.ts +12 -3
- package/src/tools/tool-config.ts +4 -1
- package/src/types.ts +52 -0
- package/src/utils/constants.ts +58 -8
- package/src/utils/error-tracker.ts +40 -1
- package/src/utils/internal-ai/complete-structured.ts +10 -4
- package/src/workflows/executors/raw-llm.ts +76 -59
- package/templates/skills/pages/content.md +205 -55
- package/templates/skills/script-workflows/config.json +14 -0
- package/templates/skills/script-workflows/content.md +68 -0
- package/templates/skills/swarm-scripts/content.md +45 -7
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-agnostic FS → DB profile sync (worker-side, HTTP-only).
|
|
3
|
+
*
|
|
4
|
+
* Persists an agent's self-editable identity / config files back to the API:
|
|
5
|
+
* - SOUL.md / IDENTITY.md / TOOLS.md / HEARTBEAT.md (bundled identity POST)
|
|
6
|
+
* - ~/.claude/CLAUDE.md (claude POST)
|
|
7
|
+
* - /workspace/start-up.sh (agent-managed section) (setup POST)
|
|
8
|
+
*
|
|
9
|
+
* This mirrors the per-session sync that the Claude plugin hooks
|
|
10
|
+
* (`src/hooks/hook.ts`) and the pi extension (`src/providers/pi-mono-extension.ts`)
|
|
11
|
+
* already perform, but lifted into a single shared module the runner can call
|
|
12
|
+
* at session end for ANY `hasLocalEnvironment` harness (claude, pi, codex,
|
|
13
|
+
* opencode). Before this module, codex/opencode had no sync path at all and
|
|
14
|
+
* pi's path could silently not-fire (2026-06-01 regression).
|
|
15
|
+
*
|
|
16
|
+
* Boundary rules (enforced by CI):
|
|
17
|
+
* - MUST NOT import `src/be/db` or `bun:sqlite` (worker/API DB boundary —
|
|
18
|
+
* `scripts/check-db-boundary.sh`). This module is HTTP-only.
|
|
19
|
+
* - MUST NOT read the API key from `process.env` directly
|
|
20
|
+
* (`scripts/check-api-key-boundary.sh`). The caller passes the key
|
|
21
|
+
* (resolved via `getApiKey()`) in `opts.apiKey`.
|
|
22
|
+
*
|
|
23
|
+
* Hardening vs. the original copies: every POST checks `resp.ok` and surfaces
|
|
24
|
+
* a scrubbed warning on a non-2xx response or thrown error instead of
|
|
25
|
+
* silently swallowing it (the swallow is exactly what hid the 2026-06-01 pi
|
|
26
|
+
* drop). The sync stays NON-FATAL — a failed sync must never fail the task —
|
|
27
|
+
* but it must be VISIBLE.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { scrubSecrets } from "../utils/secret-scrubber.ts";
|
|
31
|
+
|
|
32
|
+
export const SOUL_MD_PATH = "/workspace/SOUL.md";
|
|
33
|
+
export const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
|
|
34
|
+
export const TOOLS_MD_PATH = "/workspace/TOOLS.md";
|
|
35
|
+
export const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
|
|
36
|
+
export const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
|
|
37
|
+
/**
|
|
38
|
+
* Claude Code's personal-file CLAUDE.md path. This is what the Claude plugin
|
|
39
|
+
* Stop hook reads and owns — the runner only uses it as a backstop for an
|
|
40
|
+
* all-Claude batch (never overwriting it with the workspace materialization).
|
|
41
|
+
*/
|
|
42
|
+
export const CLAUDE_MD_PATH = `${process.env.HOME}/.claude/CLAUDE.md`;
|
|
43
|
+
/**
|
|
44
|
+
* Workspace CLAUDE.md — the agent-level instructions file the runner
|
|
45
|
+
* materializes from the `claudeMd` DB field at boot (`runner.ts`) and that the
|
|
46
|
+
* base-prompt truncation notice tells NON-Claude harnesses (codex/pi/opencode)
|
|
47
|
+
* to edit. Distinct from CLAUDE_MD_PATH; this is the FS→DB source for the
|
|
48
|
+
* non-Claude providers that previously had no sync path at all.
|
|
49
|
+
*/
|
|
50
|
+
export const WORKSPACE_CLAUDE_MD_PATH = "/workspace/CLAUDE.md";
|
|
51
|
+
|
|
52
|
+
// Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption.
|
|
53
|
+
// Mirrors `hook.ts` (raised from 100 to 500 after profile-corruption recurrences
|
|
54
|
+
// where a short test sentinel synced into the real agent's DB row).
|
|
55
|
+
const IDENTITY_FILE_MIN_LENGTH = 500;
|
|
56
|
+
// Maximum file size we are willing to sync (>64KB is almost certainly not a
|
|
57
|
+
// hand-edited identity/config file).
|
|
58
|
+
const MAX_FILE_LENGTH = 65536;
|
|
59
|
+
|
|
60
|
+
const SETUP_MARKER_START = "# === Agent-managed setup (from DB) ===";
|
|
61
|
+
const SETUP_MARKER_END = "# === End agent-managed setup ===";
|
|
62
|
+
|
|
63
|
+
export type ProfileSyncField = "identity" | "claude" | "setup";
|
|
64
|
+
export type ProfileChangeSource = "self_edit" | "session_sync";
|
|
65
|
+
|
|
66
|
+
export interface ProfileSyncOptions {
|
|
67
|
+
agentId: string;
|
|
68
|
+
apiUrl: string;
|
|
69
|
+
apiKey: string;
|
|
70
|
+
/** Session-end sync uses "session_sync"; on-edit hooks use "self_edit". */
|
|
71
|
+
changeSource?: ProfileChangeSource;
|
|
72
|
+
/** Subset of field groups to sync. Defaults to all three. */
|
|
73
|
+
fields?: ProfileSyncField[];
|
|
74
|
+
/**
|
|
75
|
+
* Path to read the CLAUDE.md source from. Defaults to CLAUDE_MD_PATH (Claude
|
|
76
|
+
* Code's personal-file path). Non-Claude local harnesses must pass
|
|
77
|
+
* WORKSPACE_CLAUDE_MD_PATH so their `/workspace/CLAUDE.md` edits sync. See
|
|
78
|
+
* `resolveClaudeMdPath`.
|
|
79
|
+
*/
|
|
80
|
+
claudeMdPath?: string;
|
|
81
|
+
/** Injectable fetch for tests. Defaults to the global `fetch`. */
|
|
82
|
+
fetchImpl?: typeof fetch;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Choose which CLAUDE.md source the runner should sync, given the harness
|
|
87
|
+
* providers of the completed local sessions in a batch. Claude Code's personal
|
|
88
|
+
* file lives at `~/.claude/CLAUDE.md` (CLAUDE_MD_PATH — the Stop hook's path);
|
|
89
|
+
* every other local harness edits `/workspace/CLAUDE.md` (the file the runner
|
|
90
|
+
* materializes and the base prompt points them to). When a batch mixes
|
|
91
|
+
* providers, the presence of any non-Claude session means the workspace file is
|
|
92
|
+
* the edited source of truth; an all-Claude batch uses the personal-file path,
|
|
93
|
+
* where the runner only acts as a backstop for a Stop hook that didn't fire and
|
|
94
|
+
* never clobbers a real personal-file edit with the stale workspace copy.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveClaudeMdPath(completedProviders: readonly string[]): string {
|
|
97
|
+
const anyNonClaude = completedProviders.some((p) => p !== "claude");
|
|
98
|
+
return anyNonClaude ? WORKSPACE_CLAUDE_MD_PATH : CLAUDE_MD_PATH;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A single profile-update POST body, tagged with a label for logging. */
|
|
102
|
+
interface ProfilePayload {
|
|
103
|
+
label: string;
|
|
104
|
+
body: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pure: given the raw `start-up.sh` contents, return the agent-managed content
|
|
109
|
+
* to sync, or `null` if there is nothing syncable. Extracts ONLY the content
|
|
110
|
+
* between the agent-managed markers when present (so operator content isn't
|
|
111
|
+
* duplicated); otherwise treats the whole file (minus a leading shebang) as
|
|
112
|
+
* agent-managed.
|
|
113
|
+
*/
|
|
114
|
+
export function extractSetupScriptContent(raw: string): string | null {
|
|
115
|
+
if (!raw.trim()) return null;
|
|
116
|
+
|
|
117
|
+
const startIdx = raw.indexOf(SETUP_MARKER_START);
|
|
118
|
+
const endIdx = raw.indexOf(SETUP_MARKER_END);
|
|
119
|
+
|
|
120
|
+
let content: string;
|
|
121
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
122
|
+
// Markers present — extract ONLY the content between them.
|
|
123
|
+
content = raw.substring(startIdx + SETUP_MARKER_START.length, endIdx).trim();
|
|
124
|
+
} else {
|
|
125
|
+
// No markers — agent created/replaced the entire file. Store as-is minus shebang.
|
|
126
|
+
content = raw.replace(/^#!\/bin\/bash\n/, "").trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!content || content.length > MAX_FILE_LENGTH) return null;
|
|
130
|
+
return content;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pure: build the bundled identity-update body from raw file contents. Applies
|
|
135
|
+
* the trim / max-length guards and the SOUL/IDENTITY min-length guard. Returns
|
|
136
|
+
* an empty object when nothing is syncable (callers should skip the POST).
|
|
137
|
+
* `undefined` inputs mean the file was absent.
|
|
138
|
+
*/
|
|
139
|
+
export function buildIdentityPayload(files: {
|
|
140
|
+
soulMd?: string;
|
|
141
|
+
identityMd?: string;
|
|
142
|
+
toolsMd?: string;
|
|
143
|
+
heartbeatMd?: string;
|
|
144
|
+
}): Record<string, string> {
|
|
145
|
+
const updates: Record<string, string> = {};
|
|
146
|
+
|
|
147
|
+
if (files.soulMd !== undefined) {
|
|
148
|
+
const content = files.soulMd;
|
|
149
|
+
if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
150
|
+
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
151
|
+
console.error(
|
|
152
|
+
`[profile-sync] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
153
|
+
);
|
|
154
|
+
} else {
|
|
155
|
+
updates.soulMd = content;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (files.identityMd !== undefined) {
|
|
161
|
+
const content = files.identityMd;
|
|
162
|
+
if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
163
|
+
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
164
|
+
console.error(
|
|
165
|
+
`[profile-sync] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
updates.identityMd = content;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (files.toolsMd !== undefined) {
|
|
174
|
+
const content = files.toolsMd;
|
|
175
|
+
if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
176
|
+
updates.toolsMd = content;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (files.heartbeatMd !== undefined) {
|
|
181
|
+
const content = files.heartbeatMd;
|
|
182
|
+
if (content.length <= MAX_FILE_LENGTH) {
|
|
183
|
+
updates.heartbeatMd = content;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return updates;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Reads a file's text, returning `undefined` when it does not exist. */
|
|
191
|
+
export type FileReader = (path: string) => Promise<string | undefined>;
|
|
192
|
+
|
|
193
|
+
/** Default file reader — reads from the worker's local FS via Bun. */
|
|
194
|
+
async function readFileIfExists(path: string): Promise<string | undefined> {
|
|
195
|
+
try {
|
|
196
|
+
const file = Bun.file(path);
|
|
197
|
+
if (!(await file.exists())) return undefined;
|
|
198
|
+
return await file.text();
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Collect the profile-update POST bodies to send. Each entry is one POST.
|
|
206
|
+
* `fields` selects which groups to include. The file reader is injectable so
|
|
207
|
+
* the field-selection / guard logic can be unit-tested without touching the FS.
|
|
208
|
+
*/
|
|
209
|
+
export async function collectProfilePayloads(
|
|
210
|
+
fields: ProfileSyncField[],
|
|
211
|
+
changeSource: ProfileChangeSource,
|
|
212
|
+
readFile: FileReader = readFileIfExists,
|
|
213
|
+
claudeMdPath: string = CLAUDE_MD_PATH,
|
|
214
|
+
): Promise<ProfilePayload[]> {
|
|
215
|
+
const payloads: ProfilePayload[] = [];
|
|
216
|
+
|
|
217
|
+
if (fields.includes("identity")) {
|
|
218
|
+
const updates = buildIdentityPayload({
|
|
219
|
+
soulMd: await readFile(SOUL_MD_PATH),
|
|
220
|
+
identityMd: await readFile(IDENTITY_MD_PATH),
|
|
221
|
+
toolsMd: await readFile(TOOLS_MD_PATH),
|
|
222
|
+
heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
|
|
223
|
+
});
|
|
224
|
+
if (Object.keys(updates).length > 0) {
|
|
225
|
+
payloads.push({ label: "identity", body: { ...updates, changeSource } });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (fields.includes("claude")) {
|
|
230
|
+
const raw = await readFile(claudeMdPath);
|
|
231
|
+
if (raw?.trim() && raw.length <= MAX_FILE_LENGTH) {
|
|
232
|
+
payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (fields.includes("setup")) {
|
|
237
|
+
const raw = await readFile(SETUP_SCRIPT_PATH);
|
|
238
|
+
if (raw !== undefined) {
|
|
239
|
+
const content = extractSetupScriptContent(raw);
|
|
240
|
+
if (content !== null) {
|
|
241
|
+
payloads.push({ label: "setup", body: { setupScript: content, changeSource } });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return payloads;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* POST a single profile update. NON-FATAL but VISIBLE: a non-2xx response or a
|
|
251
|
+
* thrown error is logged (scrubbed) and swallowed so it never fails the task,
|
|
252
|
+
* but — unlike the original copies — it is never silently ignored.
|
|
253
|
+
*/
|
|
254
|
+
export async function postProfileUpdate(
|
|
255
|
+
opts: Pick<ProfileSyncOptions, "agentId" | "apiUrl" | "apiKey" | "fetchImpl">,
|
|
256
|
+
payload: ProfilePayload,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
259
|
+
try {
|
|
260
|
+
const resp = await doFetch(`${opts.apiUrl}/api/agents/${opts.agentId}/profile`, {
|
|
261
|
+
method: "PUT",
|
|
262
|
+
headers: {
|
|
263
|
+
"Content-Type": "application/json",
|
|
264
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
265
|
+
"X-Agent-ID": opts.agentId,
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify(payload.body),
|
|
268
|
+
});
|
|
269
|
+
if (!resp.ok) {
|
|
270
|
+
let detail = "";
|
|
271
|
+
try {
|
|
272
|
+
detail = (await resp.text()).slice(0, 500);
|
|
273
|
+
} catch {
|
|
274
|
+
/* ignore body read failure */
|
|
275
|
+
}
|
|
276
|
+
console.warn(
|
|
277
|
+
scrubSecrets(
|
|
278
|
+
`[profile-sync] ${payload.label} sync failed: HTTP ${resp.status}${detail ? ` — ${detail}` : ""}`,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
284
|
+
console.warn(scrubSecrets(`[profile-sync] ${payload.label} sync errored: ${msg}`));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Sync the agent's local profile files back to the API. Reads SOUL/IDENTITY/
|
|
290
|
+
* TOOLS/HEARTBEAT/CLAUDE.md + the agent-managed section of start-up.sh and
|
|
291
|
+
* POSTs each changed group. Idempotent server-side: the profile route only
|
|
292
|
+
* writes a new `context_versions` row when the content hash changes, so a
|
|
293
|
+
* redundant sync (pi extension + runner, or an unchanged file) is a no-op.
|
|
294
|
+
*
|
|
295
|
+
* Always resolves (never throws) — failures are logged, not propagated.
|
|
296
|
+
*/
|
|
297
|
+
export async function syncProfileFilesToServer(opts: ProfileSyncOptions): Promise<void> {
|
|
298
|
+
const changeSource = opts.changeSource ?? "session_sync";
|
|
299
|
+
const fields = opts.fields ?? ["identity", "claude", "setup"];
|
|
300
|
+
|
|
301
|
+
const payloads = await collectProfilePayloads(
|
|
302
|
+
fields,
|
|
303
|
+
changeSource,
|
|
304
|
+
readFileIfExists,
|
|
305
|
+
opts.claudeMdPath ?? CLAUDE_MD_PATH,
|
|
306
|
+
);
|
|
307
|
+
for (const payload of payloads) {
|
|
308
|
+
await postProfileUpdate(opts, payload);
|
|
309
|
+
}
|
|
310
|
+
}
|
package/src/commands/runner.ts
CHANGED
|
@@ -36,12 +36,15 @@ import { initTelemetry, telemetry } from "../telemetry.ts";
|
|
|
36
36
|
import type { ProviderName, RepoGuidelines } from "../types.ts";
|
|
37
37
|
import { getApiKey } from "../utils/api-key.ts";
|
|
38
38
|
import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
|
|
39
|
+
import { getMcpBaseUrl } from "../utils/constants.ts";
|
|
39
40
|
import { getContextWindowSize } from "../utils/context-window.ts";
|
|
40
41
|
import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
|
|
41
42
|
import {
|
|
43
|
+
isCodexCreditsExhaustedMessage,
|
|
42
44
|
isRateLimitMessage,
|
|
43
45
|
MAX_RATE_LIMIT_RESET_MS,
|
|
44
46
|
parseRateLimitResetTime,
|
|
47
|
+
resolveCodexCreditsExhaustedCooldownMs,
|
|
45
48
|
} from "../utils/error-tracker.ts";
|
|
46
49
|
import { resolveHarnessProvider } from "../utils/harness-provider.ts";
|
|
47
50
|
import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
|
|
@@ -52,6 +55,7 @@ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
|
|
|
52
55
|
import { interpolate } from "../workflows/template.ts";
|
|
53
56
|
import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
|
|
54
57
|
import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
|
|
58
|
+
import { resolveClaudeMdPath, syncProfileFilesToServer } from "./profile-sync.ts";
|
|
55
59
|
import {
|
|
56
60
|
buildCredStatusReport,
|
|
57
61
|
buildLatestModelReport,
|
|
@@ -70,6 +74,34 @@ import "./templates.ts";
|
|
|
70
74
|
/** Throttle interval for progress updates (3 seconds). */
|
|
71
75
|
const PROGRESS_THROTTLE_MS = 3000;
|
|
72
76
|
|
|
77
|
+
/** Minimum spacing for explicit runner GC sweeps. */
|
|
78
|
+
const RUNNER_GC_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
79
|
+
|
|
80
|
+
let lastRunnerGcAt = 0;
|
|
81
|
+
|
|
82
|
+
type GcCapableGlobal = typeof globalThis & { gc?: () => void };
|
|
83
|
+
|
|
84
|
+
function scheduleRunnerGc(reason: string): boolean {
|
|
85
|
+
const gc = (globalThis as GcCapableGlobal).gc;
|
|
86
|
+
if (typeof gc !== "function") return false;
|
|
87
|
+
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
if (now - lastRunnerGcAt < RUNNER_GC_MIN_INTERVAL_MS) return false;
|
|
90
|
+
lastRunnerGcAt = now;
|
|
91
|
+
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
const startedAt = Date.now();
|
|
94
|
+
try {
|
|
95
|
+
gc();
|
|
96
|
+
console.log(`[runner] Explicit GC completed after ${reason} in ${Date.now() - startedAt}ms`);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.warn(`[runner] Explicit GC failed after ${reason}: ${err}`);
|
|
99
|
+
}
|
|
100
|
+
}, 0);
|
|
101
|
+
timer.unref?.();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
73
105
|
/** Save PM2 process list for persistence across container restarts */
|
|
74
106
|
async function savePm2State(role: string): Promise<void> {
|
|
75
107
|
try {
|
|
@@ -394,6 +426,7 @@ async function fetchResolvedEnv(
|
|
|
394
426
|
const RELOADABLE_ENV_KEYS: ReadonlySet<string> = new Set([
|
|
395
427
|
"MODEL_OVERRIDE",
|
|
396
428
|
"AGENT_FS_SHARED_ORG_ID",
|
|
429
|
+
"SWARM_USE_CLAUDE_BRIDGE",
|
|
397
430
|
]);
|
|
398
431
|
|
|
399
432
|
/**
|
|
@@ -1424,6 +1457,21 @@ interface RunningTask {
|
|
|
1424
1457
|
keySuffix: string;
|
|
1425
1458
|
keyIndex: number;
|
|
1426
1459
|
};
|
|
1460
|
+
/**
|
|
1461
|
+
* Harness provider this session was actually spawned/resumed on, snapshotted
|
|
1462
|
+
* at spawn time. The runner lets in-flight sessions finish on their original
|
|
1463
|
+
* adapter after a live provider swap, so the session-end profile sync must
|
|
1464
|
+
* decide based on THIS value — not the mutable global `state.harnessProvider`.
|
|
1465
|
+
*/
|
|
1466
|
+
harnessProvider: ProviderName;
|
|
1467
|
+
/**
|
|
1468
|
+
* Whether this session ran in a local `/workspace` environment, snapshotted
|
|
1469
|
+
* from `adapter.traits.hasLocalEnvironment` at spawn time. Gates the
|
|
1470
|
+
* session-end FS → DB profile sync per finished session (a session that
|
|
1471
|
+
* started local must still sync even if the worker was swapped to a remote
|
|
1472
|
+
* provider before it completed, and vice versa).
|
|
1473
|
+
*/
|
|
1474
|
+
hasLocalEnvironment: boolean;
|
|
1427
1475
|
}
|
|
1428
1476
|
|
|
1429
1477
|
/** Runner state for tracking concurrent tasks */
|
|
@@ -1438,6 +1486,13 @@ interface RunnerState {
|
|
|
1438
1486
|
* (per-task live re-resolution) will mutate this between tasks.
|
|
1439
1487
|
*/
|
|
1440
1488
|
harnessProvider: ProviderName;
|
|
1489
|
+
/**
|
|
1490
|
+
* Effective Codex credits-exhausted cooldown (ms), resolved from `swarm_config`
|
|
1491
|
+
* (key `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS`) > default 2h constant, clamped to
|
|
1492
|
+
* [5m, 7d]. Reconciled live by `applySwarmConfigDrift` — read at the cooldown
|
|
1493
|
+
* application site so a fresh value applies to the next credits-exhausted failure.
|
|
1494
|
+
*/
|
|
1495
|
+
codexCreditsExhaustedCooldownMs: number;
|
|
1441
1496
|
}
|
|
1442
1497
|
|
|
1443
1498
|
/** Buffer for session logs */
|
|
@@ -3010,6 +3065,7 @@ async function spawnProviderProcess(
|
|
|
3010
3065
|
}
|
|
3011
3066
|
closeActiveToolSpans(result.exitCode === 0 ? "ok" : "error", result.failureReason);
|
|
3012
3067
|
sessionSpan.end();
|
|
3068
|
+
scheduleRunnerGc("session completion");
|
|
3013
3069
|
|
|
3014
3070
|
return result;
|
|
3015
3071
|
}),
|
|
@@ -3024,6 +3080,7 @@ async function spawnProviderProcess(
|
|
|
3024
3080
|
});
|
|
3025
3081
|
closeActiveToolSpans("error", error instanceof Error ? error.message : String(error));
|
|
3026
3082
|
sessionSpan.end();
|
|
3083
|
+
scheduleRunnerGc("session error");
|
|
3027
3084
|
throw error;
|
|
3028
3085
|
}),
|
|
3029
3086
|
);
|
|
@@ -3046,6 +3103,11 @@ async function spawnProviderProcess(
|
|
|
3046
3103
|
promise,
|
|
3047
3104
|
result: null,
|
|
3048
3105
|
credentialInfo,
|
|
3106
|
+
// Snapshot the provider + local-env trait of the adapter this session is
|
|
3107
|
+
// spawned on, so the session-end sync decision survives a live provider
|
|
3108
|
+
// swap that mutates the global RunnerState (review finding 2).
|
|
3109
|
+
harnessProvider: opts.harnessProvider,
|
|
3110
|
+
hasLocalEnvironment: adapter.traits.hasLocalEnvironment,
|
|
3049
3111
|
};
|
|
3050
3112
|
|
|
3051
3113
|
// Non-blocking completion tracking
|
|
@@ -3073,6 +3135,8 @@ async function checkCompletedProcesses(
|
|
|
3073
3135
|
cursorUpdates?: Array<{ channelId: string; ts: string }>;
|
|
3074
3136
|
workingDir?: string;
|
|
3075
3137
|
credentialInfo?: RunningTask["credentialInfo"];
|
|
3138
|
+
harnessProvider: ProviderName;
|
|
3139
|
+
hasLocalEnvironment: boolean;
|
|
3076
3140
|
}> = [];
|
|
3077
3141
|
|
|
3078
3142
|
for (const [taskId, task] of state.activeTasks) {
|
|
@@ -3088,6 +3152,8 @@ async function checkCompletedProcesses(
|
|
|
3088
3152
|
cursorUpdates: task.cursorUpdates,
|
|
3089
3153
|
workingDir: task.workingDir,
|
|
3090
3154
|
credentialInfo: task.credentialInfo,
|
|
3155
|
+
harnessProvider: task.harnessProvider,
|
|
3156
|
+
hasLocalEnvironment: task.hasLocalEnvironment,
|
|
3091
3157
|
});
|
|
3092
3158
|
}
|
|
3093
3159
|
}
|
|
@@ -3153,6 +3219,12 @@ async function checkCompletedProcesses(
|
|
|
3153
3219
|
console.log(
|
|
3154
3220
|
`[credentials] Parsed rate limit reset time from error: ${rateLimitedUntil}`,
|
|
3155
3221
|
);
|
|
3222
|
+
} else if (isCodexCreditsExhaustedMessage(failureReason)) {
|
|
3223
|
+
const cooldownMs = state.codexCreditsExhaustedCooldownMs;
|
|
3224
|
+
rateLimitedUntil = new Date(Date.now() + cooldownMs).toISOString();
|
|
3225
|
+
console.log(
|
|
3226
|
+
`[credentials] Codex credits exhausted — applying cooldown (${cooldownMs}ms): ${rateLimitedUntil}`,
|
|
3227
|
+
);
|
|
3156
3228
|
} else {
|
|
3157
3229
|
rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
3158
3230
|
}
|
|
@@ -3217,6 +3289,40 @@ async function checkCompletedProcesses(
|
|
|
3217
3289
|
}
|
|
3218
3290
|
}
|
|
3219
3291
|
}
|
|
3292
|
+
|
|
3293
|
+
// Harness-agnostic FS → DB profile sync at session end.
|
|
3294
|
+
//
|
|
3295
|
+
// The Claude plugin Stop hook and the pi extension sync SOUL/IDENTITY/TOOLS/
|
|
3296
|
+
// CLAUDE.md + start-up.sh on their own, but codex/opencode have no such path
|
|
3297
|
+
// and pi's can silently not-fire (2026-06-01 regression). Running the sync
|
|
3298
|
+
// here — at the single point where every completed harness session converges
|
|
3299
|
+
// (including crashes, since the process resolved with an exit code) — makes
|
|
3300
|
+
// persistence reliable for ALL local-environment harnesses without
|
|
3301
|
+
// per-adapter code. Idempotent: the profile route only writes a new context
|
|
3302
|
+
// version when the content hash changes, so pi's double-sync and claude's
|
|
3303
|
+
// redundant POST collapse to a no-op. NON-FATAL — never blocks completion;
|
|
3304
|
+
// failures are logged (scrubbed) inside the helper.
|
|
3305
|
+
//
|
|
3306
|
+
// The local-env gate is per FINISHED session, snapshotted at spawn time —
|
|
3307
|
+
// NOT the mutable global `state.hasLocalEnvironment`. The runner lets
|
|
3308
|
+
// in-flight sessions finish on their original adapter after a live provider
|
|
3309
|
+
// swap, so reading the global would (a) skip a session that started local
|
|
3310
|
+
// when the worker has since flipped to a remote provider, and (b) sync stale
|
|
3311
|
+
// local files after a remote session finishes once the worker flipped local.
|
|
3312
|
+
// We sync when ANY finished session in this batch ran locally, and pick the
|
|
3313
|
+
// CLAUDE.md source from those sessions' providers (review finding 2 + 1).
|
|
3314
|
+
const localCompleted = completedTasks.filter((t) => t.hasLocalEnvironment);
|
|
3315
|
+
if (apiConfig && localCompleted.length > 0) {
|
|
3316
|
+
await syncProfileFilesToServer({
|
|
3317
|
+
agentId: apiConfig.agentId,
|
|
3318
|
+
apiUrl: apiConfig.apiUrl,
|
|
3319
|
+
apiKey: apiConfig.apiKey,
|
|
3320
|
+
changeSource: "session_sync",
|
|
3321
|
+
claudeMdPath: resolveClaudeMdPath(localCompleted.map((t) => t.harnessProvider)),
|
|
3322
|
+
}).catch((err) => {
|
|
3323
|
+
console.warn(`[${role}] ${scrubSecrets(`Profile sync failed: ${err}`)}`);
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3220
3326
|
}
|
|
3221
3327
|
|
|
3222
3328
|
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
@@ -3299,7 +3405,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3299
3405
|
// Get agent identity and swarm URL for base prompt
|
|
3300
3406
|
const agentId = process.env.AGENT_ID || "unknown";
|
|
3301
3407
|
|
|
3302
|
-
const apiUrl =
|
|
3408
|
+
const apiUrl = getMcpBaseUrl();
|
|
3303
3409
|
const swarmUrl = process.env.SWARM_URL || "localhost";
|
|
3304
3410
|
const apiKey = getApiKey();
|
|
3305
3411
|
|
|
@@ -3313,10 +3419,22 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3313
3419
|
// Failures (network, API down, malformed value) fall back to env then "claude"
|
|
3314
3420
|
// so a swarm_config outage cannot wedge boot.
|
|
3315
3421
|
let bootProvider: ProviderName;
|
|
3422
|
+
// Codex credits-exhausted cooldown is sourced solely from the global swarm_config
|
|
3423
|
+
// (key `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS`). Initialize to the default constant
|
|
3424
|
+
// for the case where it is unset, then apply the swarm_config value below; on a
|
|
3425
|
+
// boot-fetch failure it stays at the default. Reconciled live thereafter by
|
|
3426
|
+
// `applySwarmConfigDrift`.
|
|
3427
|
+
let bootCooldownMs = resolveCodexCreditsExhaustedCooldownMs(undefined);
|
|
3316
3428
|
try {
|
|
3317
|
-
|
|
3429
|
+
const bootEnv = await fetchResolvedEnv(apiUrl, apiKey, agentId);
|
|
3430
|
+
bootProvider = bootEnv.resolvedProvider;
|
|
3431
|
+
bootCooldownMs = resolveCodexCreditsExhaustedCooldownMs(
|
|
3432
|
+
bootEnv.env.CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
|
|
3433
|
+
);
|
|
3318
3434
|
} catch (err) {
|
|
3319
|
-
console.warn(
|
|
3435
|
+
console.warn(
|
|
3436
|
+
`[runner] fetchResolvedEnv failed at boot, falling back to env for provider and the default cooldown: ${err}`,
|
|
3437
|
+
);
|
|
3320
3438
|
bootProvider = resolveHarnessProvider({}, process.env);
|
|
3321
3439
|
}
|
|
3322
3440
|
console.log(`[runner] Resolved HARNESS_PROVIDER: ${bootProvider}`);
|
|
@@ -3520,6 +3638,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3520
3638
|
activeTasks: new Map(),
|
|
3521
3639
|
maxConcurrent,
|
|
3522
3640
|
harnessProvider: bootProvider,
|
|
3641
|
+
codexCreditsExhaustedCooldownMs: bootCooldownMs,
|
|
3523
3642
|
};
|
|
3524
3643
|
|
|
3525
3644
|
// Track tasks already signaled for cancellation to avoid repeated SIGTERM
|
|
@@ -3600,6 +3719,18 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3600
3719
|
agentVisibleChanged = true;
|
|
3601
3720
|
}
|
|
3602
3721
|
|
|
3722
|
+
// (2b) Codex credits-exhausted cooldown — operator-tunable live. Not
|
|
3723
|
+
// agent-visible (doesn't change provider/maxConcurrent → no re-register).
|
|
3724
|
+
const nextCooldown = resolveCodexCreditsExhaustedCooldownMs(
|
|
3725
|
+
freshEnv.CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
|
|
3726
|
+
);
|
|
3727
|
+
if (nextCooldown !== state.codexCreditsExhaustedCooldownMs) {
|
|
3728
|
+
console.log(
|
|
3729
|
+
`[${role}] [config] codexCreditsExhaustedCooldownMs: ${state.codexCreditsExhaustedCooldownMs} → ${nextCooldown}`,
|
|
3730
|
+
);
|
|
3731
|
+
state.codexCreditsExhaustedCooldownMs = nextCooldown;
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3603
3734
|
// (3) Apply the small allowlist of safe-to-mutate env keys to process.env.
|
|
3604
3735
|
const changedKeys = applyResolvedEnvToProcessEnv(freshEnv);
|
|
3605
3736
|
if (changedKeys.length > 0) {
|
package/src/hooks/hook.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../be/memory/raters/llm";
|
|
12
12
|
import type { Agent } from "../types";
|
|
13
13
|
import { getApiKey } from "../utils/api-key";
|
|
14
|
+
import { getMcpBaseUrl } from "../utils/constants";
|
|
14
15
|
import { summarizeSession as runSummarize } from "../utils/internal-ai";
|
|
15
16
|
import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
|
|
16
17
|
|
|
@@ -82,6 +83,27 @@ interface CancelledTasksResponse {
|
|
|
82
83
|
cancelled: CancelledTask[];
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Decide whether to show the "not registered — use join-swarm" nudge.
|
|
88
|
+
*
|
|
89
|
+
* Rules:
|
|
90
|
+
* 1. Only nudge on SessionStart — other events should not prompt re-registration.
|
|
91
|
+
* 2. If X-Agent-ID header is present the agent is pre-assigned; a null lookup
|
|
92
|
+
* is transient, not a real "unregistered" state → suppress the nudge.
|
|
93
|
+
* 3. Only genuinely-unregistered agents (no X-Agent-ID, null lookup, SessionStart)
|
|
94
|
+
* see the nudge.
|
|
95
|
+
*/
|
|
96
|
+
export function shouldShowRegistrationNudge(opts: {
|
|
97
|
+
agentInfoPresent: boolean;
|
|
98
|
+
eventType: string;
|
|
99
|
+
hasAgentIdHeader: boolean;
|
|
100
|
+
}): boolean {
|
|
101
|
+
if (opts.agentInfoPresent) return false;
|
|
102
|
+
if (opts.eventType !== "SessionStart") return false;
|
|
103
|
+
if (opts.hasAgentIdHeader) return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
/**
|
|
86
108
|
* Check if a path is under the agent's own subdirectory on the shared disk.
|
|
87
109
|
* Shared disk categories: thoughts, memory, downloads, misc.
|
|
@@ -150,7 +172,7 @@ async function readTaskFile(): Promise<TaskFileData | null> {
|
|
|
150
172
|
async function fetchTaskDetails(
|
|
151
173
|
taskId: string,
|
|
152
174
|
): Promise<{ id: string; task: string; progress?: string } | null> {
|
|
153
|
-
const apiUrl =
|
|
175
|
+
const apiUrl = getMcpBaseUrl();
|
|
154
176
|
const apiKey = getApiKey();
|
|
155
177
|
const headers: Record<string, string> = {};
|
|
156
178
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
@@ -889,13 +911,15 @@ export async function handleHook(): Promise<void> {
|
|
|
889
911
|
console.log(tray);
|
|
890
912
|
}
|
|
891
913
|
}
|
|
892
|
-
} else
|
|
914
|
+
} else if (
|
|
915
|
+
shouldShowRegistrationNudge({
|
|
916
|
+
agentInfoPresent: false,
|
|
917
|
+
eventType: msg.hook_event_name,
|
|
918
|
+
hasAgentIdHeader: hasAgentIdHeader(),
|
|
919
|
+
})
|
|
920
|
+
) {
|
|
893
921
|
console.log(
|
|
894
|
-
`You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info
|
|
895
|
-
|
|
896
|
-
If the ${SERVER_NAME} server is not running or disabled, disregard this message.
|
|
897
|
-
|
|
898
|
-
${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?.headers["X-Agent-ID"]}, it will be used automatically on join-swarm.` : "You do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm."}`,
|
|
922
|
+
`You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info.\n\nIf the ${SERVER_NAME} server is not running or disabled, disregard this message.\n\nYou do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm.`,
|
|
899
923
|
);
|
|
900
924
|
}
|
|
901
925
|
|
|
@@ -1151,8 +1175,7 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
|
|
|
1151
1175
|
editedPath.startsWith("/workspace/shared/memory/"))
|
|
1152
1176
|
) {
|
|
1153
1177
|
try {
|
|
1154
|
-
const apiUrl =
|
|
1155
|
-
process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
|
|
1178
|
+
const apiUrl = getMcpBaseUrl();
|
|
1156
1179
|
const apiKey = getApiKey();
|
|
1157
1180
|
const fileContent = await Bun.file(editedPath).text();
|
|
1158
1181
|
const isShared = editedPath.startsWith("/workspace/shared/");
|
package/src/http/db-query.ts
CHANGED
|
@@ -11,6 +11,24 @@ export interface DbQueryResult {
|
|
|
11
11
|
total: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export const DbQueryInputShape = {
|
|
15
|
+
sql: z.string().min(1).max(10_000).optional(),
|
|
16
|
+
query: z.string().min(1).max(10_000).optional().describe("Deprecated runtime alias for sql."),
|
|
17
|
+
params: z.array(z.any()).optional().default([]),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const DbQueryInputSchema = z
|
|
21
|
+
.object(DbQueryInputShape)
|
|
22
|
+
.refine((body) => body.sql !== undefined || body.query !== undefined, {
|
|
23
|
+
message: "Either sql or query is required",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type DbQueryInput = z.infer<typeof DbQueryInputSchema>;
|
|
27
|
+
|
|
28
|
+
export function resolveDbQuerySql(input: Pick<DbQueryInput, "sql" | "query">): string {
|
|
29
|
+
return input.sql ?? input.query ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
function stripTrailingSemicolon(sql: string): string {
|
|
15
33
|
return sql.trim().replace(/;\s*$/, "").trim();
|
|
16
34
|
}
|
|
@@ -67,10 +85,7 @@ const dbQueryRoute = route({
|
|
|
67
85
|
pattern: ["api", "db-query"],
|
|
68
86
|
summary: "Execute a read-only SQL query",
|
|
69
87
|
tags: ["Debug"],
|
|
70
|
-
body:
|
|
71
|
-
sql: z.string().min(1).max(10_000),
|
|
72
|
-
params: z.array(z.any()).optional().default([]),
|
|
73
|
-
}),
|
|
88
|
+
body: DbQueryInputSchema,
|
|
74
89
|
responses: {
|
|
75
90
|
200: {
|
|
76
91
|
description: "Query results",
|
|
@@ -100,7 +115,7 @@ export async function handleDbQuery(
|
|
|
100
115
|
if (!parsed) return true;
|
|
101
116
|
|
|
102
117
|
try {
|
|
103
|
-
const result = executeReadOnlyQuery(parsed.body
|
|
118
|
+
const result = executeReadOnlyQuery(resolveDbQuerySql(parsed.body), parsed.body.params);
|
|
104
119
|
json(res, result);
|
|
105
120
|
} catch (err: unknown) {
|
|
106
121
|
const message = err instanceof Error ? err.message : String(err);
|