@desplega.ai/agent-swarm 1.91.0 → 1.92.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.
Files changed (114) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +1005 -152
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +662 -19
  6. package/src/be/memory/constants.ts +2 -1
  7. package/src/be/memory/providers/openai-embedding.ts +2 -5
  8. package/src/be/memory/providers/sqlite-store.ts +293 -76
  9. package/src/be/memory/types.ts +35 -0
  10. package/src/be/migrations/083_script_workflows.sql +51 -0
  11. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  12. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  13. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  14. package/src/be/migrations/087_skill_files.sql +19 -0
  15. package/src/be/modelsdev-cache.json +42310 -38617
  16. package/src/be/scripts/typecheck.ts +49 -0
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +310 -6
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +506 -0
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  25. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  26. package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
  27. package/src/be/seed-scripts/index.ts +51 -5
  28. package/src/be/seed-skills/index.ts +3 -3
  29. package/src/be/skill-sync.ts +91 -7
  30. package/src/be/swarm-config-guard.ts +17 -0
  31. package/src/commands/runner.ts +49 -4
  32. package/src/heartbeat/templates.ts +20 -16
  33. package/src/http/db-query.ts +20 -5
  34. package/src/http/index.ts +51 -7
  35. package/src/http/mcp-user.ts +23 -0
  36. package/src/http/mcp.ts +58 -0
  37. package/src/http/memory.ts +58 -0
  38. package/src/http/pages.ts +1 -1
  39. package/src/http/script-runs.ts +557 -0
  40. package/src/http/scripts.ts +39 -2
  41. package/src/http/skills.ts +225 -0
  42. package/src/prompts/session-templates.ts +24 -4
  43. package/src/providers/claude-adapter.ts +107 -28
  44. package/src/script-workflows/executor.ts +110 -0
  45. package/src/script-workflows/harness.ts +73 -0
  46. package/src/script-workflows/label-lint.ts +51 -0
  47. package/src/script-workflows/limits.ts +22 -0
  48. package/src/script-workflows/supervisor.ts +139 -0
  49. package/src/script-workflows/workflow-ctx.ts +209 -0
  50. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  51. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  52. package/src/scripts-runtime/types/stdlib.d.ts +61 -0
  53. package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
  54. package/src/server.ts +4 -0
  55. package/src/slack/handlers.ts +11 -4
  56. package/src/slack/message-text.ts +98 -0
  57. package/src/slack/thread-buffer.ts +5 -3
  58. package/src/tests/claude-adapter-binary.test.ts +271 -74
  59. package/src/tests/create-page-tool.test.ts +19 -2
  60. package/src/tests/db-query.test.ts +28 -0
  61. package/src/tests/error-tracker.test.ts +121 -0
  62. package/src/tests/harness-provider-resolution.test.ts +33 -0
  63. package/src/tests/heartbeat-checklist.test.ts +36 -0
  64. package/src/tests/mcp-tools.test.ts +6 -0
  65. package/src/tests/mcp-transport-gc.test.ts +58 -0
  66. package/src/tests/memory-health-endpoint.test.ts +78 -0
  67. package/src/tests/memory-store.test.ts +221 -1
  68. package/src/tests/pages-http.test.ts +20 -2
  69. package/src/tests/pages-storage.test.ts +26 -0
  70. package/src/tests/prompt-template-session.test.ts +34 -5
  71. package/src/tests/script-runs-http.test.ts +278 -0
  72. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  73. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  74. package/src/tests/scripts-mcp-e2e.test.ts +102 -2
  75. package/src/tests/seed-scripts.test.ts +468 -3
  76. package/src/tests/skill-files-http.test.ts +171 -0
  77. package/src/tests/skill-files.test.ts +162 -0
  78. package/src/tests/skill-get-file-tool.test.ts +110 -0
  79. package/src/tests/skill-sync.test.ts +125 -6
  80. package/src/tests/slack-message-text.test.ts +250 -0
  81. package/src/tests/system-default-skills.test.ts +40 -0
  82. package/src/tools/create-page.ts +2 -2
  83. package/src/tools/db-query.ts +16 -6
  84. package/src/tools/script-runs.ts +123 -0
  85. package/src/tools/skills/index.ts +1 -0
  86. package/src/tools/skills/skill-get-file.ts +80 -0
  87. package/src/tools/slack-read.ts +12 -3
  88. package/src/tools/tool-config.ts +6 -2
  89. package/src/types.ts +72 -0
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  94. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  95. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  96. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  97. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  98. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  99. package/templates/skills/agentmail-sending/content.md +6 -7
  100. package/templates/skills/desloppify/content.md +8 -9
  101. package/templates/skills/jira-interaction/content.md +25 -33
  102. package/templates/skills/kapso-whatsapp/content.md +29 -30
  103. package/templates/skills/linear-interaction/content.md +8 -9
  104. package/templates/skills/pages/content.md +205 -55
  105. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  106. package/templates/skills/script-workflows/config.json +14 -0
  107. package/templates/skills/script-workflows/content.md +68 -0
  108. package/templates/skills/sprite-cli/content.md +4 -5
  109. package/templates/skills/swarm-scripts/content.md +2 -3
  110. package/templates/skills/turso-interaction/content.md +14 -17
  111. package/templates/skills/workflow-iterate/content.md +38 -391
  112. package/templates/skills/x-api-interactions/content.md +4 -6
  113. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  114. package/templates/skills/scheduled-task-resilience/content.md +0 -95
package/src/be/db.ts CHANGED
@@ -62,12 +62,17 @@ import type {
62
62
  RepoGuidelines,
63
63
  ScheduledTask,
64
64
  ScheduledTaskSummary,
65
+ ScriptRun,
66
+ ScriptRunJournalEntry,
67
+ ScriptRunKind,
68
+ ScriptRunStatus,
65
69
  Service,
66
70
  ServiceStatus,
67
71
  SessionCost,
68
72
  SessionCostSource,
69
73
  SessionLog,
70
74
  Skill,
75
+ SkillFile,
71
76
  SkillScope,
72
77
  SkillType,
73
78
  SkillWithInstallInfo,
@@ -110,6 +115,26 @@ export function isSqliteVecAvailable(): boolean {
110
115
  return sqliteVecAvailable;
111
116
  }
112
117
 
118
+ function loadSqliteVec(database: Database): void {
119
+ sqliteVecAvailable = false;
120
+ try {
121
+ const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
122
+ if (extensionPath) {
123
+ database.loadExtension(extensionPath);
124
+ } else {
125
+ const sqliteVec = require("sqlite-vec");
126
+ sqliteVec.load(database);
127
+ }
128
+ sqliteVecAvailable = true;
129
+ console.log(`[db] sqlite-vec loaded${extensionPath ? ` from ${extensionPath}` : ""}`);
130
+ } catch (err) {
131
+ console.warn(
132
+ "[db] sqlite-vec not available, falling back to in-memory cosine:",
133
+ (err as Error).message,
134
+ );
135
+ }
136
+ }
137
+
113
138
  export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
114
139
  if (db) {
115
140
  return db;
@@ -126,6 +151,7 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
126
151
  db = Database.deserialize(templateBytes);
127
152
  db.run("PRAGMA busy_timeout = 5000;");
128
153
  db.run("PRAGMA foreign_keys = ON;");
154
+ loadSqliteVec(db);
129
155
  configureDbResolver(resolvePromptTemplate);
130
156
  // Ensure the encryption key is resolved even when restoring from the test
131
157
  // template. The cache may have been cleared via __resetEncryptionKeyForTests
@@ -151,22 +177,7 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
151
177
  // `require.resolve("sqlite-vec-<platform>/vec0.so")` can't find the native
152
178
  // asset — so we prefer an explicit filesystem path when set, and only fall
153
179
  // back to the npm resolver for normal dev runs.
154
- try {
155
- const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
156
- if (extensionPath) {
157
- database.loadExtension(extensionPath);
158
- } else {
159
- const sqliteVec = require("sqlite-vec");
160
- sqliteVec.load(database);
161
- }
162
- sqliteVecAvailable = true;
163
- console.log(`[db] sqlite-vec loaded${extensionPath ? ` from ${extensionPath}` : ""}`);
164
- } catch (err) {
165
- console.warn(
166
- "[db] sqlite-vec not available, falling back to in-memory cosine:",
167
- (err as Error).message,
168
- );
169
- }
180
+ loadSqliteVec(database);
170
181
 
171
182
  // Run database migrations (schema creation + incremental changes)
172
183
  runMigrations(database);
@@ -344,6 +355,7 @@ export function closeDb(): void {
344
355
  db.close();
345
356
  db = null;
346
357
  }
358
+ sqliteVecAvailable = false;
347
359
  }
348
360
 
349
361
  // ============================================================================
@@ -1887,6 +1899,19 @@ export function getInProgressTasksByContextKey(
1887
1899
  .map(rowToAgentTask);
1888
1900
  }
1889
1901
 
1902
+ export function getLatestTaskByContextKey(contextKey: string): AgentTask | null {
1903
+ if (!contextKey) return null;
1904
+ const row = getDb()
1905
+ .prepare<AgentTaskRow, [string]>(
1906
+ `SELECT * FROM agent_tasks
1907
+ WHERE contextKey = ?
1908
+ ORDER BY createdAt DESC
1909
+ LIMIT 1`,
1910
+ )
1911
+ .get(contextKey);
1912
+ return row ? rowToAgentTask(row) : null;
1913
+ }
1914
+
1890
1915
  /**
1891
1916
  * Find the most recent agent associated with a specific Slack thread.
1892
1917
  * No status filter — returns the last agent that touched this thread regardless of task state.
@@ -7075,7 +7100,7 @@ export function createPage(data: {
7075
7100
  title: string;
7076
7101
  description?: string;
7077
7102
  contentType: PageContentType;
7078
- authMode: PageAuthMode;
7103
+ authMode?: PageAuthMode;
7079
7104
  passwordHash?: string;
7080
7105
  body: string;
7081
7106
  needsCredentials?: string[];
@@ -7094,7 +7119,7 @@ export function createPage(data: {
7094
7119
  data.title,
7095
7120
  data.description ?? null,
7096
7121
  data.contentType,
7097
- data.authMode,
7122
+ data.authMode ?? "authed",
7098
7123
  data.passwordHash ?? null,
7099
7124
  data.body,
7100
7125
  data.needsCredentials ? JSON.stringify(data.needsCredentials) : null,
@@ -8676,6 +8701,119 @@ function rowToSkillWithInstall(row: SkillWithInstallRow): SkillWithInstallInfo {
8676
8701
  };
8677
8702
  }
8678
8703
 
8704
+ type SkillFileRow = {
8705
+ id: string;
8706
+ skillId: string;
8707
+ path: string;
8708
+ content: string;
8709
+ mimeType: string;
8710
+ isBinary: number;
8711
+ size: number | null;
8712
+ createdAt: string;
8713
+ lastUpdatedAt: string;
8714
+ };
8715
+
8716
+ function rowToSkillFile(row: SkillFileRow): SkillFile {
8717
+ return {
8718
+ id: row.id,
8719
+ skillId: row.skillId,
8720
+ path: row.path,
8721
+ content: row.content,
8722
+ mimeType: row.mimeType,
8723
+ isBinary: row.isBinary === 1,
8724
+ size: row.size,
8725
+ createdAt: row.createdAt,
8726
+ lastUpdatedAt: row.lastUpdatedAt,
8727
+ };
8728
+ }
8729
+
8730
+ export type SkillFileInput = {
8731
+ path: string;
8732
+ content: string;
8733
+ mimeType?: string;
8734
+ isBinary?: boolean;
8735
+ size?: number | null;
8736
+ };
8737
+
8738
+ export type SkillFileManifestEntry = Omit<SkillFile, "content">;
8739
+ type NormalizedSkillFileInput = {
8740
+ path: string;
8741
+ content: string;
8742
+ mimeType: string;
8743
+ isBinary: boolean;
8744
+ size: number;
8745
+ };
8746
+
8747
+ export const SKILL_FILE_LIMITS = {
8748
+ maxCount: Number(process.env.SKILL_FILES_MAX_COUNT ?? 100),
8749
+ maxTotalBytes: Number(process.env.SKILL_FILES_MAX_TOTAL_BYTES ?? 10 * 1024 * 1024),
8750
+ maxFileBytes: Number(process.env.SKILL_FILES_MAX_FILE_BYTES ?? 500 * 1024),
8751
+ };
8752
+
8753
+ const BINARY_SKILL_FILE_PLACEHOLDER = "[binary file - not synced]";
8754
+
8755
+ export function normalizeSkillFilePath(path: string): string {
8756
+ const raw = path.trim().replace(/\\/g, "/");
8757
+ if (!raw) throw new Error("File path is required");
8758
+ if (raw.startsWith("/")) throw new Error("File path must be relative");
8759
+
8760
+ const parts = raw.split("/").filter(Boolean);
8761
+ if (parts.length === 0) throw new Error("File path is required");
8762
+ if (parts.some((part) => part === "." || part === "..")) {
8763
+ throw new Error("File path cannot contain traversal segments");
8764
+ }
8765
+
8766
+ const normalized = parts.join("/");
8767
+ if (normalized === "SKILL.md") {
8768
+ throw new Error("SKILL.md is stored on the skill record, not in skill_files");
8769
+ }
8770
+ return normalized;
8771
+ }
8772
+
8773
+ function byteSize(content: string): number {
8774
+ return Buffer.byteLength(content, "utf8");
8775
+ }
8776
+
8777
+ function normalizeSkillFileInput(input: SkillFileInput): NormalizedSkillFileInput {
8778
+ const path = normalizeSkillFilePath(input.path);
8779
+ const isBinary = input.isBinary === true;
8780
+ const content = isBinary ? input.content || BINARY_SKILL_FILE_PLACEHOLDER : input.content;
8781
+ const size = input.size ?? byteSize(content);
8782
+ if (!Number.isFinite(size) || size < 0) {
8783
+ throw new Error("File size must be a non-negative number");
8784
+ }
8785
+ if (size > SKILL_FILE_LIMITS.maxFileBytes) {
8786
+ throw new Error(`File ${path} exceeds max size ${SKILL_FILE_LIMITS.maxFileBytes}`);
8787
+ }
8788
+
8789
+ return {
8790
+ path,
8791
+ content,
8792
+ mimeType: input.mimeType ?? "text/plain",
8793
+ isBinary,
8794
+ size,
8795
+ };
8796
+ }
8797
+
8798
+ function assertSkillFileLimits(skillId: string, incoming: SkillFileInput[], replaceAll: boolean) {
8799
+ const existing = replaceAll ? [] : listSkillFileManifest(skillId);
8800
+ const byPath = new Map(existing.map((file) => [file.path, file.size ?? 0]));
8801
+
8802
+ for (const input of incoming) {
8803
+ const normalized = normalizeSkillFileInput(input);
8804
+ byPath.set(normalized.path, normalized.size);
8805
+ }
8806
+
8807
+ if (byPath.size > SKILL_FILE_LIMITS.maxCount) {
8808
+ throw new Error(`Skill file count exceeds max ${SKILL_FILE_LIMITS.maxCount}`);
8809
+ }
8810
+
8811
+ const total = [...byPath.values()].reduce((sum, size) => sum + size, 0);
8812
+ if (total > SKILL_FILE_LIMITS.maxTotalBytes) {
8813
+ throw new Error(`Skill files exceed max total size ${SKILL_FILE_LIMITS.maxTotalBytes}`);
8814
+ }
8815
+ }
8816
+
8679
8817
  export interface SkillInsert {
8680
8818
  name: string;
8681
8819
  description: string;
@@ -8849,6 +8987,124 @@ export function updateSkill(
8849
8987
  return row ? rowToSkill(row) : null;
8850
8988
  }
8851
8989
 
8990
+ function bumpSkillVersion(skillId: string, now = new Date().toISOString()) {
8991
+ getDb()
8992
+ .prepare("UPDATE skills SET version = version + 1, lastUpdatedAt = ? WHERE id = ?")
8993
+ .run(now, skillId);
8994
+ }
8995
+
8996
+ export function listSkillFileManifest(skillId: string): SkillFileManifestEntry[] {
8997
+ return getDb()
8998
+ .prepare<SkillFileRow, [string]>(
8999
+ `SELECT id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9000
+ FROM skill_files
9001
+ WHERE skillId = ?
9002
+ ORDER BY path ASC`,
9003
+ )
9004
+ .all(skillId)
9005
+ .map((row) => {
9006
+ const { content: _content, ...manifest } = rowToSkillFile(row);
9007
+ return manifest;
9008
+ });
9009
+ }
9010
+
9011
+ export function getSkillFiles(skillId: string): SkillFile[] {
9012
+ return getDb()
9013
+ .prepare<SkillFileRow, [string]>(
9014
+ `SELECT id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9015
+ FROM skill_files
9016
+ WHERE skillId = ?
9017
+ ORDER BY path ASC`,
9018
+ )
9019
+ .all(skillId)
9020
+ .map(rowToSkillFile);
9021
+ }
9022
+
9023
+ export function getSkillFile(skillId: string, path: string): SkillFile | null {
9024
+ const normalizedPath = normalizeSkillFilePath(path);
9025
+ const row = getDb()
9026
+ .prepare<SkillFileRow, [string, string]>(
9027
+ `SELECT id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9028
+ FROM skill_files
9029
+ WHERE skillId = ? AND path = ?`,
9030
+ )
9031
+ .get(skillId, normalizedPath);
9032
+ return row ? rowToSkillFile(row) : null;
9033
+ }
9034
+
9035
+ export function upsertSkillFile(skillId: string, input: SkillFileInput): SkillFile {
9036
+ const payload = normalizeSkillFileInput(input);
9037
+ assertSkillFileLimits(skillId, [payload], false);
9038
+
9039
+ const id = crypto.randomUUID();
9040
+ const now = new Date().toISOString();
9041
+ return upsertSkillFileUnchecked(skillId, payload, id, now, true);
9042
+ }
9043
+
9044
+ function upsertSkillFileUnchecked(
9045
+ skillId: string,
9046
+ payload: NormalizedSkillFileInput,
9047
+ id: string,
9048
+ now: string,
9049
+ bumpVersion: boolean,
9050
+ ): SkillFile {
9051
+ const row = getDb()
9052
+ .prepare<SkillFileRow, (string | number | null)[]>(
9053
+ `INSERT INTO skill_files (
9054
+ id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9055
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9056
+ ON CONFLICT(skillId, path) DO UPDATE SET
9057
+ content = excluded.content,
9058
+ mimeType = excluded.mimeType,
9059
+ isBinary = excluded.isBinary,
9060
+ size = excluded.size,
9061
+ lastUpdatedAt = excluded.lastUpdatedAt
9062
+ RETURNING *`,
9063
+ )
9064
+ .get(
9065
+ id,
9066
+ skillId,
9067
+ payload.path,
9068
+ payload.content,
9069
+ payload.mimeType,
9070
+ payload.isBinary ? 1 : 0,
9071
+ payload.size,
9072
+ now,
9073
+ now,
9074
+ );
9075
+
9076
+ if (!row) throw new Error("Failed to upsert skill file");
9077
+ if (bumpVersion) bumpSkillVersion(skillId, now);
9078
+ return rowToSkillFile(row);
9079
+ }
9080
+
9081
+ export function upsertSkillFiles(skillId: string, files: SkillFileInput[]): SkillFile[] {
9082
+ if (files.length === 0) return [];
9083
+ const normalized = files.map(normalizeSkillFileInput);
9084
+ assertSkillFileLimits(skillId, normalized, false);
9085
+
9086
+ const now = new Date().toISOString();
9087
+ return getDb().transaction(() => {
9088
+ const rows = normalized.map((file) =>
9089
+ upsertSkillFileUnchecked(skillId, file, crypto.randomUUID(), now, false),
9090
+ );
9091
+ bumpSkillVersion(skillId, now);
9092
+ return rows;
9093
+ })();
9094
+ }
9095
+
9096
+ export function deleteSkillFile(skillId: string, path: string): boolean {
9097
+ const normalizedPath = normalizeSkillFilePath(path);
9098
+ const result = getDb()
9099
+ .prepare("DELETE FROM skill_files WHERE skillId = ? AND path = ?")
9100
+ .run(skillId, normalizedPath);
9101
+ if (result.changes > 0) {
9102
+ bumpSkillVersion(skillId);
9103
+ return true;
9104
+ }
9105
+ return false;
9106
+ }
9107
+
8852
9108
  export function deleteSkill(id: string): boolean {
8853
9109
  const result = getDb().prepare("DELETE FROM skills WHERE id = ?").run(id);
8854
9110
  return result.changes > 0;
@@ -8991,7 +9247,7 @@ export function getAgentSkills(agentId: string, activeOnly = true): SkillWithIns
8991
9247
  SELECT s.*, 1 as isActive, s.createdAt as installedAt, 1 as sourceRank,
8992
9248
  CASE WHEN s.type = 'personal' THEN 0 ELSE 1 END as typeRank
8993
9249
  FROM skills s
8994
- WHERE s.systemDefault = 1
9250
+ WHERE (s.systemDefault = 1 OR s.scope = 'swarm')
8995
9251
  AND s.isEnabled = 1
8996
9252
  ORDER BY
8997
9253
  sourceRank,
@@ -11206,3 +11462,390 @@ export function countKv(namespace: string, opts: { prefix?: string }): number {
11206
11462
  .get(namespace, now);
11207
11463
  return row?.n ?? 0;
11208
11464
  }
11465
+
11466
+ // ─── Script Runs ────────────────────────────────────────────────────────────
11467
+
11468
+ type ScriptRunRow = {
11469
+ id: string;
11470
+ agentId: string;
11471
+ scriptName: string | null;
11472
+ source: string;
11473
+ args: string;
11474
+ kind: string;
11475
+ status: string;
11476
+ pid: number | null;
11477
+ startedAt: string;
11478
+ finishedAt: string | null;
11479
+ output: string | null;
11480
+ error: string | null;
11481
+ last_heartbeat_at: string | null;
11482
+ idempotencyKey: string | null;
11483
+ requestedByUserId: string | null;
11484
+ created_by: string | null;
11485
+ updated_by: string | null;
11486
+ };
11487
+
11488
+ function parseJsonColumn(value: string | null): unknown | undefined {
11489
+ if (value === null) return undefined;
11490
+ return JSON.parse(value);
11491
+ }
11492
+
11493
+ function rowToScriptRun(row: ScriptRunRow): ScriptRun {
11494
+ return {
11495
+ id: row.id,
11496
+ agentId: row.agentId,
11497
+ scriptName: row.scriptName ?? undefined,
11498
+ source: row.source,
11499
+ args: JSON.parse(row.args),
11500
+ kind: row.kind as ScriptRunKind,
11501
+ status: row.status as ScriptRunStatus,
11502
+ pid: row.pid ?? undefined,
11503
+ startedAt: row.startedAt,
11504
+ finishedAt: row.finishedAt ?? undefined,
11505
+ output: parseJsonColumn(row.output),
11506
+ error: row.error ?? undefined,
11507
+ lastHeartbeatAt: row.last_heartbeat_at ?? undefined,
11508
+ idempotencyKey: row.idempotencyKey ?? undefined,
11509
+ requestedByUserId: row.requestedByUserId ?? undefined,
11510
+ };
11511
+ }
11512
+
11513
+ export function createScriptRun(data: {
11514
+ id: string;
11515
+ agentId: string;
11516
+ source: string;
11517
+ args: unknown;
11518
+ scriptName?: string;
11519
+ idempotencyKey?: string;
11520
+ requestedByUserId?: string;
11521
+ createdBy?: string;
11522
+ updatedBy?: string;
11523
+ }): { run: ScriptRun; existing: boolean } {
11524
+ const db = getDb();
11525
+ if (data.idempotencyKey) {
11526
+ const existing = db
11527
+ .prepare<ScriptRunRow, [string]>("SELECT * FROM script_runs WHERE idempotencyKey = ?")
11528
+ .get(data.idempotencyKey);
11529
+ if (existing) return { run: rowToScriptRun(existing), existing: true };
11530
+ }
11531
+
11532
+ const row = db
11533
+ .prepare<
11534
+ ScriptRunRow,
11535
+ [
11536
+ string,
11537
+ string,
11538
+ string | null,
11539
+ string,
11540
+ string,
11541
+ string | null,
11542
+ string | null,
11543
+ string | null,
11544
+ string | null,
11545
+ ]
11546
+ >(
11547
+ `INSERT INTO script_runs
11548
+ (id, agentId, scriptName, source, args, idempotencyKey, requestedByUserId, created_by, updated_by)
11549
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
11550
+ RETURNING *`,
11551
+ )
11552
+ .get(
11553
+ data.id,
11554
+ data.agentId,
11555
+ data.scriptName ?? null,
11556
+ data.source,
11557
+ JSON.stringify(data.args ?? null),
11558
+ data.idempotencyKey ?? null,
11559
+ data.requestedByUserId ?? null,
11560
+ data.createdBy ?? null,
11561
+ data.updatedBy ?? data.createdBy ?? null,
11562
+ );
11563
+ if (!row) throw new Error("Failed to create script run");
11564
+ return { run: rowToScriptRun(row), existing: false };
11565
+ }
11566
+
11567
+ // Persist a synchronous inline run (POST /api/scripts/run) as an already-terminal
11568
+ // row. Unlike createScriptRun these never get a journal and never use the
11569
+ // idempotencyKey column (inline idempotency lives in the kv table).
11570
+ export function recordInlineScriptRun(data: {
11571
+ id: string;
11572
+ agentId: string;
11573
+ source: string;
11574
+ args: unknown;
11575
+ scriptName?: string;
11576
+ status: "completed" | "failed";
11577
+ output?: unknown;
11578
+ error?: string;
11579
+ startedAt: string;
11580
+ finishedAt: string;
11581
+ requestedByUserId?: string;
11582
+ createdBy?: string;
11583
+ }): ScriptRun {
11584
+ const row = getDb()
11585
+ .prepare<
11586
+ ScriptRunRow,
11587
+ [
11588
+ string,
11589
+ string,
11590
+ string | null,
11591
+ string,
11592
+ string,
11593
+ string,
11594
+ string | null,
11595
+ string | null,
11596
+ string,
11597
+ string,
11598
+ string | null,
11599
+ string | null,
11600
+ string | null,
11601
+ ]
11602
+ >(
11603
+ `INSERT INTO script_runs
11604
+ (id, agentId, scriptName, source, args, kind, status, output, error,
11605
+ startedAt, finishedAt, requestedByUserId, created_by, updated_by)
11606
+ VALUES (?, ?, ?, ?, ?, 'inline', ?, ?, ?, ?, ?, ?, ?, ?)
11607
+ RETURNING *`,
11608
+ )
11609
+ .get(
11610
+ data.id,
11611
+ data.agentId,
11612
+ data.scriptName ?? null,
11613
+ data.source,
11614
+ JSON.stringify(data.args ?? null),
11615
+ data.status,
11616
+ data.output === undefined ? null : JSON.stringify(data.output),
11617
+ data.error ?? null,
11618
+ data.startedAt,
11619
+ data.finishedAt,
11620
+ data.requestedByUserId ?? null,
11621
+ data.createdBy ?? null,
11622
+ data.createdBy ?? null,
11623
+ );
11624
+ if (!row) throw new Error("Failed to record inline script run");
11625
+ return rowToScriptRun(row);
11626
+ }
11627
+
11628
+ export function getScriptRun(id: string): ScriptRun | null {
11629
+ const row = getDb()
11630
+ .prepare<ScriptRunRow, [string]>("SELECT * FROM script_runs WHERE id = ?")
11631
+ .get(id);
11632
+ return row ? rowToScriptRun(row) : null;
11633
+ }
11634
+
11635
+ export function getScriptRunByIdempotencyKey(idempotencyKey: string): ScriptRun | null {
11636
+ const row = getDb()
11637
+ .prepare<ScriptRunRow, [string]>("SELECT * FROM script_runs WHERE idempotencyKey = ?")
11638
+ .get(idempotencyKey);
11639
+ return row ? rowToScriptRun(row) : null;
11640
+ }
11641
+
11642
+ export function listScriptRuns(opts?: {
11643
+ status?: ScriptRunStatus;
11644
+ agentId?: string;
11645
+ limit?: number;
11646
+ offset?: number;
11647
+ }): ScriptRun[] {
11648
+ const conditions: string[] = [];
11649
+ const params: Array<string | number> = [];
11650
+ if (opts?.status) {
11651
+ conditions.push("status = ?");
11652
+ params.push(opts.status);
11653
+ }
11654
+ if (opts?.agentId) {
11655
+ conditions.push("agentId = ?");
11656
+ params.push(opts.agentId);
11657
+ }
11658
+
11659
+ const limit = opts?.limit ?? 50;
11660
+ const offset = opts?.offset ?? 0;
11661
+ params.push(limit, offset);
11662
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
11663
+ const rows = getDb()
11664
+ .prepare<ScriptRunRow, Array<string | number>>(
11665
+ `SELECT * FROM script_runs ${where} ORDER BY startedAt DESC LIMIT ? OFFSET ?`,
11666
+ )
11667
+ .all(...params);
11668
+ return rows.map(rowToScriptRun);
11669
+ }
11670
+
11671
+ export function countScriptRuns(opts?: { status?: ScriptRunStatus; agentId?: string }): number {
11672
+ const conditions: string[] = [];
11673
+ const params: string[] = [];
11674
+ if (opts?.status) {
11675
+ conditions.push("status = ?");
11676
+ params.push(opts.status);
11677
+ }
11678
+ if (opts?.agentId) {
11679
+ conditions.push("agentId = ?");
11680
+ params.push(opts.agentId);
11681
+ }
11682
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
11683
+ const row = getDb()
11684
+ .prepare<{ count: number }, string[]>(`SELECT COUNT(*) AS count FROM script_runs ${where}`)
11685
+ .get(...params);
11686
+ return row?.count ?? 0;
11687
+ }
11688
+
11689
+ export function countActiveScriptRuns(): number {
11690
+ const row = getDb()
11691
+ .prepare<{ count: number }, []>(
11692
+ "SELECT COUNT(*) AS count FROM script_runs WHERE status IN ('running', 'paused')",
11693
+ )
11694
+ .get();
11695
+ return row?.count ?? 0;
11696
+ }
11697
+
11698
+ export function updateScriptRun(
11699
+ id: string,
11700
+ patch: Partial<{
11701
+ status: ScriptRunStatus;
11702
+ pid: number | null;
11703
+ finishedAt: string | null;
11704
+ output: unknown;
11705
+ error: string | null;
11706
+ lastHeartbeatAt: string | null;
11707
+ updatedBy: string | null;
11708
+ }>,
11709
+ ): void {
11710
+ const sets: string[] = [];
11711
+ const vals: Array<string | number | null> = [];
11712
+ if (patch.status !== undefined) {
11713
+ sets.push("status = ?");
11714
+ vals.push(patch.status);
11715
+ }
11716
+ if (patch.pid !== undefined) {
11717
+ sets.push("pid = ?");
11718
+ vals.push(patch.pid);
11719
+ }
11720
+ if (patch.finishedAt !== undefined) {
11721
+ sets.push("finishedAt = ?");
11722
+ vals.push(patch.finishedAt);
11723
+ }
11724
+ if ("output" in patch) {
11725
+ sets.push("output = ?");
11726
+ vals.push(patch.output === undefined ? null : JSON.stringify(patch.output));
11727
+ }
11728
+ if (patch.error !== undefined) {
11729
+ sets.push("error = ?");
11730
+ vals.push(patch.error);
11731
+ }
11732
+ if (patch.lastHeartbeatAt !== undefined) {
11733
+ sets.push("last_heartbeat_at = ?");
11734
+ vals.push(patch.lastHeartbeatAt);
11735
+ }
11736
+ if (patch.updatedBy !== undefined) {
11737
+ sets.push("updated_by = ?");
11738
+ vals.push(patch.updatedBy);
11739
+ }
11740
+ if (sets.length === 0) return;
11741
+ vals.push(id);
11742
+ getDb().run(`UPDATE script_runs SET ${sets.join(", ")} WHERE id = ?`, vals);
11743
+ }
11744
+
11745
+ export function getRunningScriptRuns(): ScriptRun[] {
11746
+ const rows = getDb()
11747
+ .prepare<ScriptRunRow, []>("SELECT * FROM script_runs WHERE status IN ('running', 'paused')")
11748
+ .all();
11749
+ return rows.map(rowToScriptRun);
11750
+ }
11751
+
11752
+ // ─── Script Run Journal ─────────────────────────────────────────────────────
11753
+
11754
+ type ScriptRunJournalRow = {
11755
+ id: string;
11756
+ runId: string;
11757
+ stepKey: string;
11758
+ stepType: string;
11759
+ config: string;
11760
+ status: string;
11761
+ result: string | null;
11762
+ error: string | null;
11763
+ startedAt: string;
11764
+ completedAt: string | null;
11765
+ durationMs: number | null;
11766
+ created_by: string | null;
11767
+ updated_by: string | null;
11768
+ };
11769
+
11770
+ function rowToScriptRunJournalEntry(row: ScriptRunJournalRow): ScriptRunJournalEntry {
11771
+ return {
11772
+ id: row.id,
11773
+ runId: row.runId,
11774
+ stepKey: row.stepKey,
11775
+ stepType: row.stepType,
11776
+ config: JSON.parse(row.config),
11777
+ status: row.status as "completed" | "failed",
11778
+ result: parseJsonColumn(row.result),
11779
+ error: row.error ?? undefined,
11780
+ startedAt: row.startedAt,
11781
+ completedAt: row.completedAt ?? undefined,
11782
+ durationMs: row.durationMs ?? undefined,
11783
+ };
11784
+ }
11785
+
11786
+ export function getScriptRunJournalStep(
11787
+ runId: string,
11788
+ stepKey: string,
11789
+ ): ScriptRunJournalEntry | null {
11790
+ const row = getDb()
11791
+ .prepare<ScriptRunJournalRow, [string, string]>(
11792
+ "SELECT * FROM script_run_journal WHERE runId = ? AND stepKey = ?",
11793
+ )
11794
+ .get(runId, stepKey);
11795
+ return row ? rowToScriptRunJournalEntry(row) : null;
11796
+ }
11797
+
11798
+ export function upsertScriptRunJournalStep(data: {
11799
+ runId: string;
11800
+ stepKey: string;
11801
+ stepType: string;
11802
+ config: unknown;
11803
+ status: "completed" | "failed";
11804
+ result?: unknown;
11805
+ error?: string;
11806
+ durationMs?: number;
11807
+ }): void {
11808
+ getDb().run(
11809
+ `INSERT OR IGNORE INTO script_run_journal
11810
+ (id, runId, stepKey, stepType, config, status, result, error, durationMs, completedAt)
11811
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
11812
+ [
11813
+ crypto.randomUUID(),
11814
+ data.runId,
11815
+ data.stepKey,
11816
+ data.stepType,
11817
+ JSON.stringify(data.config ?? {}),
11818
+ data.status,
11819
+ data.result !== undefined ? JSON.stringify(data.result) : null,
11820
+ data.error ?? null,
11821
+ data.durationMs ?? null,
11822
+ ],
11823
+ );
11824
+ }
11825
+
11826
+ export function listScriptRunJournalSteps(runId: string): ScriptRunJournalEntry[] {
11827
+ const rows = getDb()
11828
+ .prepare<ScriptRunJournalRow, [string]>(
11829
+ "SELECT * FROM script_run_journal WHERE runId = ? ORDER BY startedAt ASC",
11830
+ )
11831
+ .all(runId);
11832
+ return rows.map(rowToScriptRunJournalEntry);
11833
+ }
11834
+
11835
+ export function countScriptRunJournalSteps(runId: string): number {
11836
+ const row = getDb()
11837
+ .prepare<{ count: number }, [string]>(
11838
+ "SELECT COUNT(*) AS count FROM script_run_journal WHERE runId = ?",
11839
+ )
11840
+ .get(runId);
11841
+ return row?.count ?? 0;
11842
+ }
11843
+
11844
+ export function countScriptRunJournalAgentTaskSteps(runId: string): number {
11845
+ const row = getDb()
11846
+ .prepare<{ count: number }, [string]>(
11847
+ "SELECT COUNT(*) AS count FROM script_run_journal WHERE runId = ? AND stepType = 'agent-task'",
11848
+ )
11849
+ .get(runId);
11850
+ return row?.count ?? 0;
11851
+ }