@tt-a1i/hive 2.0.2 → 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.
- package/CHANGELOG.md +33 -0
- package/README.en.md +15 -6
- package/README.md +26 -4
- package/dist/src/cli/hive.d.ts +4 -0
- package/dist/src/cli/hive.js +25 -3
- package/dist/src/cli/team.d.ts +8 -1
- package/dist/src/cli/team.js +111 -11
- package/dist/src/server/action-center-summary.d.ts +193 -0
- package/dist/src/server/action-center-summary.js +188 -0
- package/dist/src/server/agent-command-resolver.d.ts +6 -0
- package/dist/src/server/agent-command-resolver.js +16 -0
- package/dist/src/server/agent-manager.js +11 -1
- package/dist/src/server/agent-run-starter.js +47 -6
- package/dist/src/server/agent-runtime-types.d.ts +4 -0
- package/dist/src/server/agent-startup-instructions.d.ts +4 -0
- package/dist/src/server/agent-startup-instructions.js +35 -9
- package/dist/src/server/agent-stdin-dispatcher.js +17 -9
- package/dist/src/server/diagnostics-support-bundle.d.ts +288 -0
- package/dist/src/server/diagnostics-support-bundle.js +179 -0
- package/dist/src/server/dispatch-ledger-store.d.ts +4 -1
- package/dist/src/server/dispatch-ledger-store.js +46 -6
- package/dist/src/server/hive-envelope-escape.d.ts +2 -0
- package/dist/src/server/hive-envelope-escape.js +2 -0
- package/dist/src/server/hive-team-guidance.d.ts +1 -1
- package/dist/src/server/hive-team-guidance.js +67 -25
- package/dist/src/server/message-log-store.d.ts +1 -1
- package/dist/src/server/post-start-input-writer.js +8 -2
- package/dist/src/server/preset-launch-support.d.ts +2 -0
- package/dist/src/server/preset-launch-support.js +65 -2
- package/dist/src/server/protocol-event-stats.d.ts +39 -0
- package/dist/src/server/protocol-event-stats.js +84 -0
- package/dist/src/server/recovery-summary.js +19 -14
- package/dist/src/server/role-template-store.d.ts +1 -1
- package/dist/src/server/role-templates.d.ts +1 -0
- package/dist/src/server/role-templates.js +43 -29
- package/dist/src/server/routes-action-center.d.ts +2 -0
- package/dist/src/server/routes-action-center.js +37 -0
- package/dist/src/server/routes-diagnostics.d.ts +2 -0
- package/dist/src/server/routes-diagnostics.js +17 -0
- package/dist/src/server/routes-scenarios.d.ts +25 -0
- package/dist/src/server/routes-scenarios.js +89 -0
- package/dist/src/server/routes-settings.js +2 -11
- package/dist/src/server/routes-team-memory.js +52 -0
- package/dist/src/server/routes-team.js +40 -20
- package/dist/src/server/routes-workspace-memory-dreams.js +8 -0
- package/dist/src/server/routes-workspace-uploads.d.ts +2 -0
- package/dist/src/server/routes-workspace-uploads.js +154 -0
- package/dist/src/server/routes-workspaces.js +29 -3
- package/dist/src/server/routes.js +8 -0
- package/dist/src/server/runtime-message-builders.d.ts +0 -1
- package/dist/src/server/runtime-message-builders.js +0 -8
- package/dist/src/server/runtime-store-contract.d.ts +15 -0
- package/dist/src/server/runtime-store-dream.d.ts +14 -1
- package/dist/src/server/runtime-store-dream.js +49 -1
- package/dist/src/server/runtime-store-helpers.d.ts +7 -0
- package/dist/src/server/runtime-store-helpers.js +85 -22
- package/dist/src/server/runtime-store-worker-mutations.d.ts +11 -0
- package/dist/src/server/runtime-store-worker-mutations.js +46 -0
- package/dist/src/server/runtime-store-workflows.js +10 -6
- package/dist/src/server/runtime-store.js +34 -42
- package/dist/src/server/scenario-presets.d.ts +25 -0
- package/dist/src/server/scenario-presets.js +35 -0
- package/dist/src/server/sentinel-heartbeat.d.ts +30 -0
- package/dist/src/server/sentinel-heartbeat.js +145 -0
- package/dist/src/server/spawn-cli-resolver.d.ts +37 -0
- package/dist/src/server/spawn-cli-resolver.js +70 -0
- package/dist/src/server/spawn-worker-defaults.d.ts +13 -0
- package/dist/src/server/spawn-worker-defaults.js +45 -0
- package/dist/src/server/sqlite-schema-v32.d.ts +2 -0
- package/dist/src/server/sqlite-schema-v32.js +17 -0
- package/dist/src/server/sqlite-schema-v33.d.ts +3 -0
- package/dist/src/server/sqlite-schema-v33.js +18 -0
- package/dist/src/server/sqlite-schema-v34.d.ts +11 -0
- package/dist/src/server/sqlite-schema-v34.js +19 -0
- package/dist/src/server/sqlite-schema-v35.d.ts +3 -0
- package/dist/src/server/sqlite-schema-v35.js +23 -0
- package/dist/src/server/sqlite-schema.d.ts +1 -1
- package/dist/src/server/sqlite-schema.js +35 -1
- package/dist/src/server/system-message.d.ts +5 -2
- package/dist/src/server/system-message.js +5 -2
- package/dist/src/server/tasks-file-watcher.d.ts +8 -0
- package/dist/src/server/tasks-file-watcher.js +31 -2
- package/dist/src/server/team-authz.d.ts +9 -1
- package/dist/src/server/team-authz.js +24 -0
- package/dist/src/server/team-list-serializer.d.ts +2 -2
- package/dist/src/server/team-list-serializer.js +2 -1
- package/dist/src/server/team-memory-digest.js +4 -4
- package/dist/src/server/team-memory-dream-applier.js +24 -3
- package/dist/src/server/team-memory-dream-prompt.d.ts +13 -0
- package/dist/src/server/team-memory-dream-prompt.js +91 -0
- package/dist/src/server/team-memory-dream-run-store.d.ts +2 -0
- package/dist/src/server/team-memory-dream-run-store.js +14 -4
- package/dist/src/server/team-memory-dream-runner.d.ts +2 -21
- package/dist/src/server/team-memory-dream-runner.js +3 -148
- package/dist/src/server/team-memory-dream-store.d.ts +1 -1
- package/dist/src/server/team-memory-dream-store.js +1 -1
- package/dist/src/server/team-operations.d.ts +18 -2
- package/dist/src/server/team-operations.js +222 -33
- package/dist/src/server/team-recap.d.ts +10 -0
- package/dist/src/server/team-recap.js +73 -0
- package/dist/src/server/terminal-input-profile.js +88 -9
- package/dist/src/server/upload-limits.d.ts +2 -0
- package/dist/src/server/upload-limits.js +2 -0
- package/dist/src/server/workflow-cli-policy.d.ts +7 -2
- package/dist/src/server/workflow-cli-policy.js +15 -3
- package/dist/src/server/workflow-run-store.d.ts +1 -0
- package/dist/src/server/workflow-run-store.js +11 -1
- package/dist/src/server/workflow-runner.d.ts +4 -1
- package/dist/src/server/workflow-runner.js +418 -118
- package/dist/src/server/workflow-script-loader.d.ts +3 -2
- package/dist/src/server/workflow-script-loader.js +161 -0
- package/dist/src/server/workspace-store-contract.d.ts +2 -0
- package/dist/src/server/workspace-store.d.ts +1 -1
- package/dist/src/server/workspace-store.js +40 -30
- package/dist/src/server/workspace-upload-store.d.ts +40 -0
- package/dist/src/server/workspace-upload-store.js +295 -0
- package/dist/src/shared/scenario-presets.d.ts +32 -0
- package/dist/src/shared/scenario-presets.js +69 -0
- package/dist/src/shared/types.d.ts +12 -1
- package/package.json +1 -1
- package/web/dist/assets/AddWorkerDialog-DBLhwb91.js +2 -0
- package/web/dist/assets/AddWorkspaceFlow-cxvhVAsT.js +1 -0
- package/web/dist/assets/FirstRunWizard-DlEPnWWw.js +1 -0
- package/web/dist/assets/{MarketplaceDrawer-Dd8WIA8T.js → MarketplaceDrawer-CfSiRi8e.js} +11 -11
- package/web/dist/assets/TaskGraphDrawer-C2JufcPs.js +1 -0
- package/web/dist/assets/WhatsNewDialog-vP7buLos.js +1 -0
- package/web/dist/assets/WorkerModal-CSorwcdP.js +1 -0
- package/web/dist/assets/{WorkflowsDrawer-Bjf4olbR.js → WorkflowsDrawer-BXS3w9Uq.js} +1 -1
- package/web/dist/assets/WorkspaceMemoryDrawer-D71ivohr.js +1 -0
- package/web/dist/assets/{WorkspaceTaskDrawer-BIWwISvA.js → WorkspaceTaskDrawer-CGCTSHKa.js} +1 -1
- package/web/dist/assets/index-BcwN8cCw.js +79 -0
- package/web/dist/assets/index-StXTPHls.css +1 -0
- package/web/dist/assets/{search-Bk2HQvO7.js → search-BZw4T67h.js} +1 -1
- package/web/dist/assets/{square-terminal-D93m9hfY.js → square-terminal-B7E57In1.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +1 -1
- package/dist/src/server/env-sync-message.d.ts +0 -9
- package/dist/src/server/env-sync-message.js +0 -29
- package/web/dist/assets/AddWorkerDialog-CbV75qUX.js +0 -2
- package/web/dist/assets/AddWorkspaceFlow-CwV-7wPx.js +0 -1
- package/web/dist/assets/FirstRunWizard-a6PWIK3x.js +0 -1
- package/web/dist/assets/TaskGraphDrawer-Bk5WFIk_.js +0 -1
- package/web/dist/assets/WhatsNewDialog-C2VZaip0.js +0 -1
- package/web/dist/assets/WorkerModal-DucW-9YT.js +0 -1
- package/web/dist/assets/WorkspaceMemoryDrawer-DglCy_5f.js +0 -1
- package/web/dist/assets/index-BAiLYajK.css +0 -1
- package/web/dist/assets/index-BV2k9Dts.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
|
|
24
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|