@tt-a1i/hive 2.0.1 → 2.1.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.en.md +15 -6
  3. package/README.md +26 -4
  4. package/dist/src/cli/hive.d.ts +4 -0
  5. package/dist/src/cli/hive.js +25 -3
  6. package/dist/src/cli/team.d.ts +8 -1
  7. package/dist/src/cli/team.js +111 -11
  8. package/dist/src/server/action-center-summary.d.ts +193 -0
  9. package/dist/src/server/action-center-summary.js +188 -0
  10. package/dist/src/server/agent-command-resolver.d.ts +6 -0
  11. package/dist/src/server/agent-command-resolver.js +16 -0
  12. package/dist/src/server/agent-manager.js +11 -1
  13. package/dist/src/server/agent-run-starter.js +47 -6
  14. package/dist/src/server/agent-runtime-types.d.ts +4 -0
  15. package/dist/src/server/agent-startup-instructions.d.ts +4 -0
  16. package/dist/src/server/agent-startup-instructions.js +35 -9
  17. package/dist/src/server/agent-stdin-dispatcher.js +17 -9
  18. package/dist/src/server/diagnostics-support-bundle.d.ts +288 -0
  19. package/dist/src/server/diagnostics-support-bundle.js +179 -0
  20. package/dist/src/server/dispatch-ledger-store.d.ts +4 -1
  21. package/dist/src/server/dispatch-ledger-store.js +46 -6
  22. package/dist/src/server/hive-envelope-escape.d.ts +2 -0
  23. package/dist/src/server/hive-envelope-escape.js +2 -0
  24. package/dist/src/server/hive-team-guidance.d.ts +1 -1
  25. package/dist/src/server/hive-team-guidance.js +67 -25
  26. package/dist/src/server/message-log-store.d.ts +1 -1
  27. package/dist/src/server/post-start-input-writer.js +8 -2
  28. package/dist/src/server/preset-launch-support.d.ts +2 -0
  29. package/dist/src/server/preset-launch-support.js +65 -2
  30. package/dist/src/server/protocol-event-stats.d.ts +39 -0
  31. package/dist/src/server/protocol-event-stats.js +84 -0
  32. package/dist/src/server/recovery-summary.js +19 -14
  33. package/dist/src/server/role-template-store.d.ts +1 -1
  34. package/dist/src/server/role-templates.d.ts +1 -0
  35. package/dist/src/server/role-templates.js +43 -29
  36. package/dist/src/server/routes-action-center.d.ts +2 -0
  37. package/dist/src/server/routes-action-center.js +37 -0
  38. package/dist/src/server/routes-diagnostics.d.ts +2 -0
  39. package/dist/src/server/routes-diagnostics.js +17 -0
  40. package/dist/src/server/routes-scenarios.d.ts +25 -0
  41. package/dist/src/server/routes-scenarios.js +89 -0
  42. package/dist/src/server/routes-settings.js +2 -11
  43. package/dist/src/server/routes-team-memory.js +52 -0
  44. package/dist/src/server/routes-team.js +40 -20
  45. package/dist/src/server/routes-workspace-memory-dreams.js +8 -0
  46. package/dist/src/server/routes-workspace-uploads.d.ts +2 -0
  47. package/dist/src/server/routes-workspace-uploads.js +154 -0
  48. package/dist/src/server/routes-workspaces.js +29 -3
  49. package/dist/src/server/routes.js +8 -0
  50. package/dist/src/server/runtime-message-builders.d.ts +0 -1
  51. package/dist/src/server/runtime-message-builders.js +0 -8
  52. package/dist/src/server/runtime-store-contract.d.ts +15 -0
  53. package/dist/src/server/runtime-store-dream.d.ts +14 -1
  54. package/dist/src/server/runtime-store-dream.js +49 -1
  55. package/dist/src/server/runtime-store-helpers.d.ts +7 -0
  56. package/dist/src/server/runtime-store-helpers.js +85 -22
  57. package/dist/src/server/runtime-store-worker-mutations.d.ts +11 -0
  58. package/dist/src/server/runtime-store-worker-mutations.js +46 -0
  59. package/dist/src/server/runtime-store-workflows.js +10 -6
  60. package/dist/src/server/runtime-store.js +34 -42
  61. package/dist/src/server/scenario-presets.d.ts +25 -0
  62. package/dist/src/server/scenario-presets.js +35 -0
  63. package/dist/src/server/sentinel-heartbeat.d.ts +30 -0
  64. package/dist/src/server/sentinel-heartbeat.js +145 -0
  65. package/dist/src/server/spawn-cli-resolver.d.ts +37 -0
  66. package/dist/src/server/spawn-cli-resolver.js +70 -0
  67. package/dist/src/server/spawn-worker-defaults.d.ts +13 -0
  68. package/dist/src/server/spawn-worker-defaults.js +45 -0
  69. package/dist/src/server/sqlite-schema-v32.d.ts +2 -0
  70. package/dist/src/server/sqlite-schema-v32.js +17 -0
  71. package/dist/src/server/sqlite-schema-v33.d.ts +3 -0
  72. package/dist/src/server/sqlite-schema-v33.js +18 -0
  73. package/dist/src/server/sqlite-schema-v34.d.ts +11 -0
  74. package/dist/src/server/sqlite-schema-v34.js +19 -0
  75. package/dist/src/server/sqlite-schema-v35.d.ts +3 -0
  76. package/dist/src/server/sqlite-schema-v35.js +23 -0
  77. package/dist/src/server/sqlite-schema.d.ts +1 -1
  78. package/dist/src/server/sqlite-schema.js +35 -1
  79. package/dist/src/server/system-message.d.ts +5 -2
  80. package/dist/src/server/system-message.js +5 -2
  81. package/dist/src/server/tasks-file-watcher.d.ts +8 -0
  82. package/dist/src/server/tasks-file-watcher.js +31 -2
  83. package/dist/src/server/team-authz.d.ts +9 -1
  84. package/dist/src/server/team-authz.js +24 -0
  85. package/dist/src/server/team-list-serializer.d.ts +2 -2
  86. package/dist/src/server/team-list-serializer.js +2 -1
  87. package/dist/src/server/team-memory-digest.js +4 -4
  88. package/dist/src/server/team-memory-dream-applier.js +24 -3
  89. package/dist/src/server/team-memory-dream-prompt.d.ts +13 -0
  90. package/dist/src/server/team-memory-dream-prompt.js +91 -0
  91. package/dist/src/server/team-memory-dream-run-store.d.ts +2 -0
  92. package/dist/src/server/team-memory-dream-run-store.js +14 -4
  93. package/dist/src/server/team-memory-dream-runner.d.ts +2 -21
  94. package/dist/src/server/team-memory-dream-runner.js +3 -148
  95. package/dist/src/server/team-memory-dream-store.d.ts +1 -1
  96. package/dist/src/server/team-memory-dream-store.js +1 -1
  97. package/dist/src/server/team-operations.d.ts +18 -2
  98. package/dist/src/server/team-operations.js +222 -33
  99. package/dist/src/server/team-recap.d.ts +10 -0
  100. package/dist/src/server/team-recap.js +73 -0
  101. package/dist/src/server/terminal-input-profile.js +95 -6
  102. package/dist/src/server/upload-limits.d.ts +2 -0
  103. package/dist/src/server/upload-limits.js +2 -0
  104. package/dist/src/server/workflow-cli-policy.d.ts +7 -2
  105. package/dist/src/server/workflow-cli-policy.js +15 -3
  106. package/dist/src/server/workflow-run-store.d.ts +1 -0
  107. package/dist/src/server/workflow-run-store.js +11 -1
  108. package/dist/src/server/workflow-runner.d.ts +4 -1
  109. package/dist/src/server/workflow-runner.js +418 -118
  110. package/dist/src/server/workflow-script-loader.d.ts +3 -2
  111. package/dist/src/server/workflow-script-loader.js +161 -0
  112. package/dist/src/server/workspace-store-contract.d.ts +2 -0
  113. package/dist/src/server/workspace-store.d.ts +1 -1
  114. package/dist/src/server/workspace-store.js +40 -30
  115. package/dist/src/server/workspace-upload-store.d.ts +40 -0
  116. package/dist/src/server/workspace-upload-store.js +295 -0
  117. package/dist/src/shared/scenario-presets.d.ts +32 -0
  118. package/dist/src/shared/scenario-presets.js +69 -0
  119. package/dist/src/shared/types.d.ts +12 -1
  120. package/package.json +1 -1
  121. package/web/dist/assets/AddWorkerDialog-DBLhwb91.js +2 -0
  122. package/web/dist/assets/AddWorkspaceFlow-cxvhVAsT.js +1 -0
  123. package/web/dist/assets/FirstRunWizard-DlEPnWWw.js +1 -0
  124. package/web/dist/assets/{MarketplaceDrawer-BFfGT8hH.js → MarketplaceDrawer-CfSiRi8e.js} +11 -11
  125. package/web/dist/assets/TaskGraphDrawer-C2JufcPs.js +1 -0
  126. package/web/dist/assets/WhatsNewDialog-vP7buLos.js +1 -0
  127. package/web/dist/assets/WorkerModal-CSorwcdP.js +1 -0
  128. package/web/dist/assets/{WorkflowsDrawer-CiIdHS6_.js → WorkflowsDrawer-BXS3w9Uq.js} +1 -1
  129. package/web/dist/assets/WorkspaceMemoryDrawer-D71ivohr.js +1 -0
  130. package/web/dist/assets/{WorkspaceTaskDrawer-CyhhEB1Z.js → WorkspaceTaskDrawer-CGCTSHKa.js} +1 -1
  131. package/web/dist/assets/index-BcwN8cCw.js +79 -0
  132. package/web/dist/assets/index-StXTPHls.css +1 -0
  133. package/web/dist/assets/{search-BtRkkEmS.js → search-BZw4T67h.js} +1 -1
  134. package/web/dist/assets/{square-terminal-lEeQUWb3.js → square-terminal-B7E57In1.js} +1 -1
  135. package/web/dist/index.html +2 -2
  136. package/web/dist/sw.js +1 -1
  137. package/dist/src/server/env-sync-message.d.ts +0 -9
  138. package/dist/src/server/env-sync-message.js +0 -29
  139. package/web/dist/assets/AddWorkerDialog-C86CwNgQ.js +0 -2
  140. package/web/dist/assets/AddWorkspaceFlow-Bm2Jz34D.js +0 -1
  141. package/web/dist/assets/FirstRunWizard-XzBoEpA5.js +0 -1
  142. package/web/dist/assets/TaskGraphDrawer-_uVH_0C1.js +0 -1
  143. package/web/dist/assets/WhatsNewDialog-DkJHmkMs.js +0 -1
  144. package/web/dist/assets/WorkerModal-BtMJEOG9.js +0 -1
  145. package/web/dist/assets/WorkspaceMemoryDrawer-C6sNocl_.js +0 -1
  146. package/web/dist/assets/index-BAiLYajK.css +0 -1
  147. package/web/dist/assets/index-K-GG8UwR.js +0 -73
@@ -20,14 +20,15 @@ export interface LoadedWorkflow {
20
20
  meta: WorkflowMeta;
21
21
  scriptPath: string;
22
22
  scriptHash: string;
23
- /** Transpiled `async function __wf(...) {…}` — the runner evals it via
24
- * `new Function(source + '; return __wf')()` and calls it with the DSL. */
23
+ /** Transpiled `async function __wf(...) {…}` — the runner executes it in a
24
+ * locked-down VM context and calls it with the Hive DSL bridge functions. */
25
25
  compiledFunctionSource: string;
26
26
  }
27
27
  interface ExtractedMeta {
28
28
  meta: WorkflowMeta;
29
29
  body: string;
30
30
  }
31
+ export declare const assertWorkflowScriptIsSandboxable: (source: string) => void;
31
32
  export declare const extractMeta: (source: string) => ExtractedMeta;
32
33
  export declare const loadWorkflowScriptSource: (source: string, scriptPath: string) => Promise<LoadedWorkflow>;
33
34
  export declare const loadWorkflowScriptFile: (absPath: string) => Promise<LoadedWorkflow>;
@@ -1,6 +1,165 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  const DSL_PARAMS = 'agent, parallel, pipeline, phase, log, workflow, args';
4
+ const DANGEROUS_WORKFLOW_IDENTIFIERS = new Set([
5
+ 'AsyncFunction',
6
+ 'Bun',
7
+ 'Buffer',
8
+ 'Deno',
9
+ 'Function',
10
+ 'Proxy',
11
+ 'Reflect',
12
+ 'WebAssembly',
13
+ 'XMLHttpRequest',
14
+ '__proto__',
15
+ 'constructor',
16
+ 'document',
17
+ 'eval',
18
+ 'exports',
19
+ 'fetch',
20
+ 'global',
21
+ 'globalThis',
22
+ 'import',
23
+ 'module',
24
+ 'process',
25
+ 'prototype',
26
+ 'queueMicrotask',
27
+ 'require',
28
+ 'setImmediate',
29
+ 'setInterval',
30
+ 'setTimeout',
31
+ 'this',
32
+ 'window',
33
+ ]);
34
+ const META_LITERAL_KEYWORDS = new Set(['false', 'null', 'true']);
35
+ const isIdentStart = (ch) => /[$A-Z_a-z]/.test(ch);
36
+ const isIdentPart = (ch) => /[$0-9A-Z_a-z]/.test(ch);
37
+ const scanCodeTokens = (source) => {
38
+ const tokens = [];
39
+ const modes = [
40
+ { kind: 'code', braceDepth: -1 },
41
+ ];
42
+ let i = 0;
43
+ while (i < source.length) {
44
+ const mode = modes[modes.length - 1];
45
+ if (!mode)
46
+ break;
47
+ const ch = source.charAt(i);
48
+ const next = source.charAt(i + 1);
49
+ if (mode.kind === 'template') {
50
+ if (ch === '\\') {
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if (ch === '`') {
55
+ modes.pop();
56
+ i += 1;
57
+ continue;
58
+ }
59
+ if (ch === '$' && next === '{') {
60
+ modes.push({ kind: 'code', braceDepth: 0 });
61
+ i += 2;
62
+ continue;
63
+ }
64
+ i += 1;
65
+ continue;
66
+ }
67
+ if (ch === '/' && next === '/') {
68
+ const nl = source.indexOf('\n', i + 2);
69
+ i = nl === -1 ? source.length : nl + 1;
70
+ continue;
71
+ }
72
+ if (ch === '/' && next === '*') {
73
+ const end = source.indexOf('*/', i + 2);
74
+ i = end === -1 ? source.length : end + 2;
75
+ continue;
76
+ }
77
+ if (ch === "'" || ch === '"') {
78
+ const quote = ch;
79
+ i += 1;
80
+ while (i < source.length) {
81
+ if (source.charAt(i) === '\\') {
82
+ i += 2;
83
+ continue;
84
+ }
85
+ if (source.charAt(i) === quote) {
86
+ i += 1;
87
+ break;
88
+ }
89
+ i += 1;
90
+ }
91
+ continue;
92
+ }
93
+ if (ch === '`') {
94
+ modes.push({ kind: 'template', braceDepth: -1 });
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (mode.braceDepth >= 0 && ch === '}') {
99
+ if (mode.braceDepth === 0) {
100
+ modes.pop();
101
+ }
102
+ else {
103
+ mode.braceDepth -= 1;
104
+ tokens.push({ index: i, type: 'punct', value: ch });
105
+ }
106
+ i += 1;
107
+ continue;
108
+ }
109
+ if (ch === '{') {
110
+ if (mode.braceDepth >= 0)
111
+ mode.braceDepth += 1;
112
+ tokens.push({ index: i, type: 'punct', value: ch });
113
+ i += 1;
114
+ continue;
115
+ }
116
+ if (/[0-9]/.test(ch)) {
117
+ const match = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(source.slice(i));
118
+ if (match) {
119
+ tokens.push({ index: i, type: 'number', value: match[0] });
120
+ i += match[0].length;
121
+ continue;
122
+ }
123
+ }
124
+ if (isIdentStart(ch)) {
125
+ const start = i;
126
+ i += 1;
127
+ while (i < source.length && isIdentPart(source.charAt(i)))
128
+ i += 1;
129
+ tokens.push({ index: start, type: 'identifier', value: source.slice(start, i) });
130
+ continue;
131
+ }
132
+ if (!/\s/.test(ch))
133
+ tokens.push({ index: i, type: 'punct', value: ch });
134
+ i += 1;
135
+ }
136
+ return tokens;
137
+ };
138
+ export const assertWorkflowScriptIsSandboxable = (source) => {
139
+ for (const token of scanCodeTokens(source)) {
140
+ if (token.type === 'identifier' && DANGEROUS_WORKFLOW_IDENTIFIERS.has(token.value)) {
141
+ throw new Error(`workflow scripts may not use \`${token.value}\`; use the Hive DSL and put file, shell, network, or validation work inside agent() prompts`);
142
+ }
143
+ }
144
+ };
145
+ const assertPlainMetaLiteral = (literal) => {
146
+ const tokens = scanCodeTokens(literal);
147
+ for (let i = 0; i < tokens.length; i += 1) {
148
+ const token = tokens[i];
149
+ if (!token)
150
+ continue;
151
+ if (token.type === 'identifier') {
152
+ const nextToken = tokens[i + 1];
153
+ const isObjectKey = nextToken?.type === 'punct' && nextToken.value === ':';
154
+ if (!isObjectKey && !META_LITERAL_KEYWORDS.has(token.value)) {
155
+ throw new Error(`workflow meta must be a pure literal; unexpected identifier \`${token.value}\``);
156
+ }
157
+ }
158
+ else if (token.type === 'punct' && !['{', '}', '[', ']', ':', ','].includes(token.value)) {
159
+ throw new Error(`workflow meta must be a pure literal; unexpected token \`${token.value}\``);
160
+ }
161
+ }
162
+ };
4
163
  // Find the matching close brace for the object literal whose '{' is at
5
164
  // openIndex, ignoring braces inside '...' "..." `...` strings and // /* */
6
165
  // comments so they don't miscount the depth.
@@ -56,6 +215,7 @@ export const extractMeta = (source) => {
56
215
  const braceStart = source.indexOf('{', m.index + m[0].length - 1);
57
216
  const braceEnd = matchBrace(source, braceStart);
58
217
  const literal = source.slice(braceStart, braceEnd + 1);
218
+ assertPlainMetaLiteral(literal);
59
219
  let meta;
60
220
  try {
61
221
  // meta MUST be a pure literal (no calls/vars); eval it in isolation.
@@ -93,6 +253,7 @@ export const loadWorkflowScriptSource = async (source, scriptPath) => {
93
253
  throw new Error('workflow scripts may not use `import`; use the ambient DSL + inline schemas');
94
254
  }
95
255
  const { meta, body } = extractMeta(source);
256
+ assertWorkflowScriptIsSandboxable(body);
96
257
  const wrapped = `async function __wf(${DSL_PARAMS}) {\n${body}\n}`;
97
258
  // Lazy-load esbuild: its native binary breaks under jsdom/worker contexts
98
259
  // used by the web test suite, so importing it at module-load time would
@@ -14,6 +14,8 @@ export interface WorkspaceStore {
14
14
  addWorker: (workspaceId: string, input: WorkerInput) => AgentSummary;
15
15
  createWorkspace: (path: string, name: string) => WorkspaceSummary;
16
16
  deleteWorkspace: (workspaceId: string) => void;
17
+ deleteWorkspaceData: (workspaceId: string) => void;
18
+ forgetWorkspace: (workspaceId: string) => void;
17
19
  deleteWorker: (workspaceId: string, workerId: string) => void;
18
20
  renameWorker: (workspaceId: string, workerId: string, name: string) => AgentSummary;
19
21
  getAgent: (workspaceId: string, agentId: string) => AgentSummary;
@@ -2,4 +2,4 @@ import type { Database } from 'better-sqlite3';
2
2
  import type { WorkerInput, WorkspaceRecord, WorkspaceStore } from './workspace-store-contract.js';
3
3
  import { type MessageKindRecord } from './workspace-store-support.js';
4
4
  export type { WorkerInput, WorkspaceRecord, WorkspaceStore };
5
- export declare const createWorkspaceStore: (db: Database, listOpenDispatchKinds: () => MessageKindRecord[]) => WorkspaceStore;
5
+ export declare const createWorkspaceStore: (db: Database, listOpenDispatchKindsInput: (() => MessageKindRecord[]) | MessageKindRecord[]) => WorkspaceStore;
@@ -13,7 +13,10 @@ const normalizeWorkerName = (name) => {
13
13
  throw new Error('Worker name must be 64 characters or fewer');
14
14
  return trimmed;
15
15
  };
16
- export const createWorkspaceStore = (db, listOpenDispatchKinds) => {
16
+ export const createWorkspaceStore = (db, listOpenDispatchKindsInput) => {
17
+ const listOpenDispatchKinds = typeof listOpenDispatchKindsInput === 'function'
18
+ ? listOpenDispatchKindsInput
19
+ : () => listOpenDispatchKindsInput;
17
20
  const workspaces = new Map();
18
21
  seedWorkspacesFromDb(db, workspaces, listOpenDispatchKinds());
19
22
  const syncPendingFromDispatchLedger = (workspace) => {
@@ -40,6 +43,37 @@ export const createWorkspaceStore = (db, listOpenDispatchKinds) => {
40
43
  syncPendingFromDispatchLedger(workspace);
41
44
  return workspace;
42
45
  };
46
+ const deleteWorkspaceData = (workspaceId) => {
47
+ const workspace = getWorkspace(workspaceId);
48
+ const agentIds = workspace.agents.map((agent) => agent.id);
49
+ db.transaction(() => {
50
+ db.prepare('DELETE FROM messages WHERE workspace_id = ?').run(workspaceId);
51
+ db.prepare('DELETE FROM agent_launch_configs WHERE workspace_id = ?').run(workspaceId);
52
+ db.prepare('DELETE FROM agent_sessions WHERE workspace_id = ?').run(workspaceId);
53
+ const deleteAgentRuns = db.prepare('DELETE FROM agent_runs WHERE agent_id = ?');
54
+ for (const agentId of agentIds)
55
+ deleteAgentRuns.run(agentId);
56
+ db.prepare('DELETE FROM workers WHERE workspace_id = ?').run(workspaceId);
57
+ // TIER 1 #4 — cascade workflow tables. Without this, the scheduler's
58
+ // listDueSchedules keeps firing schedules for the dead workspace
59
+ // every minute (the startWorkflow then crashes in
60
+ // getWorkflowAgentId / addWorkerWithLaunch and `nextRunAt` is
61
+ // rewritten to fire again next tick — a permanent error-spam
62
+ // loop). Orphan workflow_runs / dispatches would otherwise also
63
+ // accumulate forever. The dispatches DELETE in particular hits the
64
+ // workflow-tagged subset; non-workflow dispatches were already
65
+ // cleared via the deleteWorker cascade above.
66
+ db.prepare('DELETE FROM workflow_schedules WHERE workspace_id = ?').run(workspaceId);
67
+ // TIER 2 #3 — also wipe the log table; FK is on run_id, so we
68
+ // have to clear it BEFORE deleting workflow_runs (the lookup
69
+ // would otherwise miss the rows we're about to delete).
70
+ db.prepare(`DELETE FROM workflow_run_logs
71
+ WHERE run_id IN (SELECT id FROM workflow_runs WHERE workspace_id = ?)`).run(workspaceId);
72
+ db.prepare('DELETE FROM workflow_runs WHERE workspace_id = ?').run(workspaceId);
73
+ db.prepare('DELETE FROM dispatches WHERE workspace_id = ?').run(workspaceId);
74
+ db.prepare('DELETE FROM workspaces WHERE id = ?').run(workspaceId);
75
+ })();
76
+ };
43
77
  return {
44
78
  addWorker(workspaceId, input) {
45
79
  const workspace = getWorkspace(workspaceId);
@@ -75,35 +109,11 @@ export const createWorkspaceStore = (db, listOpenDispatchKinds) => {
75
109
  return summary;
76
110
  },
77
111
  deleteWorkspace(workspaceId) {
78
- const workspace = getWorkspace(workspaceId);
79
- const agentIds = workspace.agents.map((agent) => agent.id);
80
- db.transaction(() => {
81
- db.prepare('DELETE FROM messages WHERE workspace_id = ?').run(workspaceId);
82
- db.prepare('DELETE FROM agent_launch_configs WHERE workspace_id = ?').run(workspaceId);
83
- db.prepare('DELETE FROM agent_sessions WHERE workspace_id = ?').run(workspaceId);
84
- const deleteAgentRuns = db.prepare('DELETE FROM agent_runs WHERE agent_id = ?');
85
- for (const agentId of agentIds)
86
- deleteAgentRuns.run(agentId);
87
- db.prepare('DELETE FROM workers WHERE workspace_id = ?').run(workspaceId);
88
- // TIER 1 #4 — cascade workflow tables. Without this, the scheduler's
89
- // listDueSchedules keeps firing schedules for the dead workspace
90
- // every minute (the startWorkflow then crashes in
91
- // getWorkflowAgentId / addWorkerWithLaunch and `nextRunAt` is
92
- // rewritten to fire again next tick — a permanent error-spam
93
- // loop). Orphan workflow_runs / dispatches would otherwise also
94
- // accumulate forever. The dispatches DELETE in particular hits the
95
- // workflow-tagged subset; non-workflow dispatches were already
96
- // cleared via the deleteWorker cascade above.
97
- db.prepare('DELETE FROM workflow_schedules WHERE workspace_id = ?').run(workspaceId);
98
- // TIER 2 #3 — also wipe the log table; FK is on run_id, so we
99
- // have to clear it BEFORE deleting workflow_runs (the lookup
100
- // would otherwise miss the rows we're about to delete).
101
- db.prepare(`DELETE FROM workflow_run_logs
102
- WHERE run_id IN (SELECT id FROM workflow_runs WHERE workspace_id = ?)`).run(workspaceId);
103
- db.prepare('DELETE FROM workflow_runs WHERE workspace_id = ?').run(workspaceId);
104
- db.prepare('DELETE FROM dispatches WHERE workspace_id = ?').run(workspaceId);
105
- db.prepare('DELETE FROM workspaces WHERE id = ?').run(workspaceId);
106
- })();
112
+ deleteWorkspaceData(workspaceId);
113
+ workspaces.delete(workspaceId);
114
+ },
115
+ deleteWorkspaceData,
116
+ forgetWorkspace(workspaceId) {
107
117
  workspaces.delete(workspaceId);
108
118
  },
109
119
  renameWorker(workspaceId, workerId, name) {
@@ -0,0 +1,40 @@
1
+ import type { Database } from 'better-sqlite3';
2
+ export interface SaveWorkspaceUploadInput {
3
+ workspaceId: string;
4
+ remoteDeviceId?: string | null;
5
+ originalName: string;
6
+ mimeType?: string | null;
7
+ data: Buffer;
8
+ }
9
+ export interface WorkspaceUploadRecord {
10
+ id: string;
11
+ workspaceId: string;
12
+ remoteDeviceId: string | null;
13
+ originalName: string;
14
+ mimeType: string;
15
+ sizeBytes: number;
16
+ createdAt: number;
17
+ }
18
+ export interface StagedWorkspaceUploadsDelete {
19
+ /** Permanently remove the staged blobs once the transaction has committed. */
20
+ commit: () => void;
21
+ /** Restore the staged blobs after the surrounding transaction rolled back. */
22
+ rollback: () => void;
23
+ }
24
+ export declare const createWorkspaceUploadStore: (db: Database, uploadsDir: string) => {
25
+ saveUpload(input: SaveWorkspaceUploadInput): Promise<WorkspaceUploadRecord>;
26
+ listUploads(workspaceId: string, limit?: number): WorkspaceUploadRecord[];
27
+ readUpload(workspaceId: string, uploadId: string): Promise<{
28
+ data: Buffer;
29
+ record: WorkspaceUploadRecord;
30
+ } | undefined>;
31
+ /**
32
+ * Phase one of workspace-upload deletion; call inside the data-mutation
33
+ * transaction. Rows are deleted and blobs renamed to `.deleting-*`
34
+ * tombstones, but nothing is permanently unlinked until `commit()` runs
35
+ * after the transaction commits — so a failed COMMIT can still
36
+ * `rollback()` and restore every blob. Tombstones orphaned by a crash
37
+ * between the phases are reconciled by the startup sweep.
38
+ */
39
+ stageWorkspaceUploadsDelete(workspaceId: string): StagedWorkspaceUploadsDelete;
40
+ };
@@ -0,0 +1,295 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readdirSync, renameSync, rmdirSync, unlinkSync } from 'node:fs';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, extname, join, resolve, sep } from 'node:path';
5
+ import { HttpError } from './http-errors.js';
6
+ const DEFAULT_UPLOAD_NAME = 'upload';
7
+ const DEFAULT_MIME_TYPE = 'application/octet-stream';
8
+ const MAX_NAME_LENGTH = 180;
9
+ const MAX_MIME_TYPE_LENGTH = 120;
10
+ const MIME_TYPE_RE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+(?:\s*;\s*[A-Za-z0-9!#$&^_.+-]+=[A-Za-z0-9!#$&^_.+-]+)*$/;
11
+ const stripControlCharacters = (value) => {
12
+ let result = '';
13
+ for (const char of value) {
14
+ const code = char.charCodeAt(0);
15
+ if (code >= 32 && code !== 127)
16
+ result += char;
17
+ }
18
+ return result;
19
+ };
20
+ const isSafeHeaderValue = (value) => {
21
+ for (const char of value) {
22
+ const code = char.charCodeAt(0);
23
+ if (code < 32 || code > 126 || code === 127)
24
+ return false;
25
+ }
26
+ return true;
27
+ };
28
+ const mapRow = (row) => ({
29
+ id: row.id,
30
+ workspaceId: row.workspace_id,
31
+ remoteDeviceId: row.remote_device_id,
32
+ originalName: row.original_name,
33
+ mimeType: row.mime_type,
34
+ sizeBytes: row.size_bytes,
35
+ createdAt: row.created_at,
36
+ });
37
+ const sanitizeOriginalName = (value) => {
38
+ const leaf = stripControlCharacters(basename(value.replaceAll('\\', '/')))
39
+ .trim()
40
+ .replace(/\s+/g, ' ');
41
+ return leaf ? leaf.slice(0, MAX_NAME_LENGTH) : DEFAULT_UPLOAD_NAME;
42
+ };
43
+ const sanitizeMimeType = (value) => {
44
+ if (!value)
45
+ return DEFAULT_MIME_TYPE;
46
+ const trimmed = value.trim();
47
+ if (!trimmed ||
48
+ trimmed.length > MAX_MIME_TYPE_LENGTH ||
49
+ !isSafeHeaderValue(trimmed) ||
50
+ !MIME_TYPE_RE.test(trimmed)) {
51
+ return DEFAULT_MIME_TYPE;
52
+ }
53
+ return trimmed;
54
+ };
55
+ const safeStorageExtension = (originalName) => {
56
+ const extension = extname(originalName);
57
+ if (!extension || extension.length > 16)
58
+ return '';
59
+ // Require at least one non-dot character: `extname('foo.')` returns `'.'`,
60
+ // and win32 silently strips trailing dots on disk, so the stored name would
61
+ // no longer round-trip with `storage_key`.
62
+ return /^\.[A-Za-z0-9_-]+$/.test(extension) ? extension : '';
63
+ };
64
+ const isEnoent = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
65
+ const createStorageKey = (workspaceId, uploadId, originalName) => `${workspaceId}/${uploadId}${safeStorageExtension(originalName)}`;
66
+ const resolveStoragePath = (uploadsDir, storageKey) => {
67
+ const root = resolve(uploadsDir);
68
+ const candidate = resolve(root, ...storageKey.split('/'));
69
+ if (candidate === root || !candidate.startsWith(`${root}${sep}`)) {
70
+ throw new HttpError(500, 'Upload storage key is invalid');
71
+ }
72
+ return candidate;
73
+ };
74
+ const readRowDataStrict = async (uploadsDir, row) => {
75
+ try {
76
+ return await readFile(resolveStoragePath(uploadsDir, row.storage_key));
77
+ }
78
+ catch (error) {
79
+ if (isEnoent(error))
80
+ return null;
81
+ throw new HttpError(500, 'Upload file could not be read');
82
+ }
83
+ };
84
+ const pruneWorkspaceUploadDir = (uploadsDir, workspaceId) => {
85
+ try {
86
+ rmdirSync(resolveStoragePath(uploadsDir, workspaceId));
87
+ }
88
+ catch {
89
+ // Directory may be absent already, or may contain files from a concurrent upload.
90
+ }
91
+ };
92
+ const removeFile = (path) => {
93
+ try {
94
+ unlinkSync(path);
95
+ }
96
+ catch {
97
+ // Best-effort cleanup only. A missing or locked file should not block
98
+ // workspace deletion or rollback from a failed metadata insert.
99
+ }
100
+ };
101
+ const restoreStagedFiles = (staged) => {
102
+ for (let index = staged.length - 1; index >= 0; index -= 1) {
103
+ const item = staged[index];
104
+ if (!item)
105
+ continue;
106
+ try {
107
+ renameSync(item.stagedPath, item.originalPath);
108
+ }
109
+ catch {
110
+ // Best effort: if rollback restoration fails, preserve the original
111
+ // exception because it names the operation that made deletion unsafe.
112
+ }
113
+ }
114
+ };
115
+ const STAGED_DELETE_SUFFIX_RE = /\.deleting-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
116
+ /**
117
+ * Reconcile `.deleting-*` tombstones left by a crash between staging and the
118
+ * surrounding transaction outcome: a surviving DB row means the delete rolled
119
+ * back, so the blob is restored; no row means it committed, so the blob goes.
120
+ */
121
+ const sweepStagedUploadFiles = (db, uploadsDir) => {
122
+ const hasRowForStorageKey = db.prepare('SELECT 1 FROM workspace_uploads WHERE storage_key = ?');
123
+ let workspaceDirs;
124
+ try {
125
+ workspaceDirs = readdirSync(uploadsDir);
126
+ }
127
+ catch {
128
+ return;
129
+ }
130
+ for (const workspaceId of workspaceDirs) {
131
+ let entries;
132
+ try {
133
+ entries = readdirSync(join(uploadsDir, workspaceId));
134
+ }
135
+ catch {
136
+ continue;
137
+ }
138
+ for (const entry of entries) {
139
+ if (!STAGED_DELETE_SUFFIX_RE.test(entry))
140
+ continue;
141
+ const stagedPath = join(uploadsDir, workspaceId, entry);
142
+ const originalName = entry.replace(STAGED_DELETE_SUFFIX_RE, '');
143
+ try {
144
+ if (hasRowForStorageKey.get(`${workspaceId}/${originalName}`)) {
145
+ renameSync(stagedPath, join(uploadsDir, workspaceId, originalName));
146
+ }
147
+ else {
148
+ unlinkSync(stagedPath);
149
+ }
150
+ }
151
+ catch {
152
+ // Locked or vanished tombstones wait for the next boot.
153
+ }
154
+ }
155
+ }
156
+ };
157
+ const stageFilesForDelete = (uploadsDir, rows) => {
158
+ const staged = [];
159
+ try {
160
+ for (const row of rows) {
161
+ const originalPath = resolveStoragePath(uploadsDir, row.storage_key);
162
+ const stagedPath = `${originalPath}.deleting-${randomUUID()}`;
163
+ try {
164
+ renameSync(originalPath, stagedPath);
165
+ }
166
+ catch (error) {
167
+ if (isEnoent(error))
168
+ continue;
169
+ throw error;
170
+ }
171
+ staged.push({ originalPath, stagedPath });
172
+ }
173
+ }
174
+ catch (error) {
175
+ restoreStagedFiles(staged);
176
+ if (error instanceof HttpError)
177
+ throw error;
178
+ throw new HttpError(500, 'Workspace upload file could not be deleted');
179
+ }
180
+ return staged;
181
+ };
182
+ export const createWorkspaceUploadStore = (db, uploadsDir) => {
183
+ const insertUpload = db.prepare(`INSERT INTO workspace_uploads (
184
+ id, workspace_id, remote_device_id, original_name, mime_type, size_bytes, storage_key,
185
+ created_at
186
+ )
187
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?
188
+ WHERE EXISTS (SELECT 1 FROM workspaces WHERE id = ?)`);
189
+ const workspaceExists = db.prepare('SELECT 1 FROM workspaces WHERE id = ?');
190
+ const getUpload = db.prepare(`SELECT id, workspace_id, remote_device_id, original_name, mime_type, size_bytes, storage_key,
191
+ created_at
192
+ FROM workspace_uploads
193
+ WHERE workspace_id = ? AND id = ?`);
194
+ const listUploads = db.prepare(`SELECT id, workspace_id, remote_device_id, original_name, mime_type, size_bytes, storage_key,
195
+ created_at
196
+ FROM workspace_uploads
197
+ WHERE workspace_id = ?
198
+ ORDER BY created_at DESC, id DESC
199
+ LIMIT ?`);
200
+ const listUploadsForDelete = db.prepare(`SELECT id, workspace_id, remote_device_id, original_name, mime_type, size_bytes, storage_key,
201
+ created_at
202
+ FROM workspace_uploads
203
+ WHERE workspace_id = ?`);
204
+ const deleteWorkspaceRows = db.prepare('DELETE FROM workspace_uploads WHERE workspace_id = ?');
205
+ sweepStagedUploadFiles(db, uploadsDir);
206
+ return {
207
+ async saveUpload(input) {
208
+ if (!workspaceExists.get(input.workspaceId)) {
209
+ throw new HttpError(404, 'Workspace not found');
210
+ }
211
+ const now = Date.now();
212
+ const originalName = sanitizeOriginalName(input.originalName);
213
+ const id = randomUUID();
214
+ const storageKey = createStorageKey(input.workspaceId, id, originalName);
215
+ const storagePath = resolveStoragePath(uploadsDir, storageKey);
216
+ const row = {
217
+ id,
218
+ workspace_id: input.workspaceId,
219
+ remote_device_id: input.remoteDeviceId ?? null,
220
+ original_name: originalName,
221
+ mime_type: sanitizeMimeType(input.mimeType),
222
+ size_bytes: input.data.byteLength,
223
+ storage_key: storageKey,
224
+ created_at: now,
225
+ };
226
+ try {
227
+ await mkdir(dirname(storagePath), { recursive: true });
228
+ // Async IO keeps a 100MB write from stalling every PTY/WebSocket on
229
+ // the event loop; `wx` keeps the no-clobber guarantee. The INSERT's
230
+ // WHERE EXISTS guard plus the removeFile cleanup below still cover a
231
+ // workspace deleted while the write was in flight.
232
+ await writeFile(storagePath, input.data, { flag: 'wx' });
233
+ }
234
+ catch {
235
+ throw new HttpError(500, 'Upload could not be saved');
236
+ }
237
+ try {
238
+ const result = insertUpload.run(row.id, row.workspace_id, row.remote_device_id, row.original_name, row.mime_type, row.size_bytes, row.storage_key, row.created_at, row.workspace_id);
239
+ if (result.changes !== 1) {
240
+ removeFile(storagePath);
241
+ throw new HttpError(404, 'Workspace not found');
242
+ }
243
+ }
244
+ catch (error) {
245
+ removeFile(storagePath);
246
+ if (error instanceof HttpError)
247
+ throw error;
248
+ // Raw driver errors (e.g. "database or disk is full") would otherwise
249
+ // reach the client via the app-level error serializer.
250
+ throw new HttpError(500, 'Upload could not be saved');
251
+ }
252
+ return mapRow(row);
253
+ },
254
+ listUploads(workspaceId, limit = 50) {
255
+ const safeLimit = Number.isSafeInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 50;
256
+ return listUploads.all(workspaceId, safeLimit).map(mapRow);
257
+ },
258
+ async readUpload(workspaceId, uploadId) {
259
+ const row = getUpload.get(workspaceId, uploadId);
260
+ if (!row)
261
+ return undefined;
262
+ const data = await readRowDataStrict(uploadsDir, row);
263
+ if (!data)
264
+ return undefined;
265
+ return { data, record: mapRow(row) };
266
+ },
267
+ /**
268
+ * Phase one of workspace-upload deletion; call inside the data-mutation
269
+ * transaction. Rows are deleted and blobs renamed to `.deleting-*`
270
+ * tombstones, but nothing is permanently unlinked until `commit()` runs
271
+ * after the transaction commits — so a failed COMMIT can still
272
+ * `rollback()` and restore every blob. Tombstones orphaned by a crash
273
+ * between the phases are reconciled by the startup sweep.
274
+ */
275
+ stageWorkspaceUploadsDelete(workspaceId) {
276
+ const rows = listUploadsForDelete.all(workspaceId);
277
+ const staged = stageFilesForDelete(uploadsDir, rows);
278
+ try {
279
+ deleteWorkspaceRows.run(workspaceId);
280
+ }
281
+ catch (error) {
282
+ restoreStagedFiles(staged);
283
+ throw error;
284
+ }
285
+ return {
286
+ commit: () => {
287
+ for (const item of staged)
288
+ removeFile(item.stagedPath);
289
+ pruneWorkspaceUploadDir(uploadsDir, workspaceId);
290
+ },
291
+ rollback: () => restoreStagedFiles(staged),
292
+ };
293
+ },
294
+ };
295
+ };
@@ -0,0 +1,32 @@
1
+ import type { WorkerRole } from './types.js';
2
+ /**
3
+ * Scenario team presets — the data behind "one-click team assembly".
4
+ *
5
+ * Lives in `src/shared/` (same pattern as `open-targets.ts`) because both
6
+ * sides consume it as a value import: the server route materializes the
7
+ * workers, the web UI renders the cards and prefills the goal template.
8
+ * Everything here is pure data — no node imports, safe to bundle.
9
+ */
10
+ export type ScenarioId = 'build_review_test' | 'research_factcheck' | 'docs_pipeline';
11
+ export interface ScenarioWorkerSpec {
12
+ /** Stable stem for the server-generated worker name (`<stem>-<suffix>`). */
13
+ nameStem: string;
14
+ role: WorkerRole;
15
+ /**
16
+ * Role contract for `custom` workers, injected as the worker description
17
+ * (startup prompt + every dispatch). Built-in roles omit this and fall back
18
+ * to their default description from `role-templates.ts`.
19
+ */
20
+ descriptionOverride?: string;
21
+ }
22
+ export interface ScenarioPreset {
23
+ id: ScenarioId;
24
+ workers: ScenarioWorkerSpec[];
25
+ /** Placeholder goal the UI prefills for the user to edit before applying. */
26
+ goalTemplate: {
27
+ en: string;
28
+ zh: string;
29
+ };
30
+ }
31
+ export declare const SCENARIO_PRESETS: ScenarioPreset[];
32
+ export declare const getScenarioPreset: (id: string) => ScenarioPreset | undefined;