@tt-a1i/hive 1.4.3 → 1.5.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 (180) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.en.md +5 -4
  3. package/README.md +9 -1
  4. package/assets/qq-group.jpg +0 -0
  5. package/dist/bin/team.cmd +1 -0
  6. package/dist/src/cli/hive-update.d.ts +57 -0
  7. package/dist/src/cli/hive-update.js +92 -15
  8. package/dist/src/cli/hive.d.ts +57 -0
  9. package/dist/src/cli/hive.js +113 -20
  10. package/dist/src/cli/team.d.ts +1 -0
  11. package/dist/src/cli/team.js +215 -7
  12. package/dist/src/server/agent-command-resolver.d.ts +10 -1
  13. package/dist/src/server/agent-command-resolver.js +32 -4
  14. package/dist/src/server/agent-launch-resolver.js +9 -3
  15. package/dist/src/server/agent-manager-support.d.ts +28 -0
  16. package/dist/src/server/agent-manager-support.js +138 -10
  17. package/dist/src/server/agent-run-bootstrap.d.ts +17 -1
  18. package/dist/src/server/agent-run-bootstrap.js +30 -2
  19. package/dist/src/server/agent-run-starter.d.ts +7 -1
  20. package/dist/src/server/agent-run-starter.js +9 -2
  21. package/dist/src/server/agent-run-store.d.ts +1 -1
  22. package/dist/src/server/agent-runtime-close.d.ts +1 -0
  23. package/dist/src/server/agent-runtime-close.js +25 -1
  24. package/dist/src/server/agent-runtime-contract.d.ts +2 -1
  25. package/dist/src/server/agent-runtime.d.ts +1 -1
  26. package/dist/src/server/agent-runtime.js +8 -2
  27. package/dist/src/server/agent-startup-instructions.d.ts +8 -1
  28. package/dist/src/server/agent-startup-instructions.js +15 -9
  29. package/dist/src/server/agent-stdin-dispatcher.d.ts +12 -5
  30. package/dist/src/server/agent-stdin-dispatcher.js +129 -40
  31. package/dist/src/server/app.d.ts +1 -0
  32. package/dist/src/server/app.js +12 -2
  33. package/dist/src/server/cron-util.d.ts +7 -0
  34. package/dist/src/server/cron-util.js +19 -0
  35. package/dist/src/server/dispatch-ledger-store.d.ts +22 -0
  36. package/dist/src/server/dispatch-ledger-store.js +51 -3
  37. package/dist/src/server/env-sync-message.js +9 -9
  38. package/dist/src/server/fs-browse.d.ts +14 -1
  39. package/dist/src/server/fs-browse.js +48 -5
  40. package/dist/src/server/fs-pick-folder.js +58 -11
  41. package/dist/src/server/fs-sandbox.js +36 -7
  42. package/dist/src/server/hive-team-guidance.d.ts +11 -6
  43. package/dist/src/server/hive-team-guidance.js +252 -70
  44. package/dist/src/server/live-run-registry.d.ts +1 -0
  45. package/dist/src/server/live-run-registry.js +1 -1
  46. package/dist/src/server/open-target-commands.js +29 -4
  47. package/dist/src/server/orchestrator-autostart.d.ts +12 -0
  48. package/dist/src/server/orchestrator-autostart.js +15 -13
  49. package/dist/src/server/path-canonicalization.d.ts +3 -0
  50. package/dist/src/server/path-canonicalization.js +29 -0
  51. package/dist/src/server/platform-path.d.ts +3 -0
  52. package/dist/src/server/platform-path.js +13 -0
  53. package/dist/src/server/post-start-input-writer.d.ts +1 -1
  54. package/dist/src/server/post-start-input-writer.js +116 -16
  55. package/dist/src/server/preset-launch-support.d.ts +1 -1
  56. package/dist/src/server/preset-launch-support.js +33 -2
  57. package/dist/src/server/recovery-summary.d.ts +6 -1
  58. package/dist/src/server/recovery-summary.js +17 -17
  59. package/dist/src/server/restart-policy-support.d.ts +6 -1
  60. package/dist/src/server/restart-policy-support.js +9 -1
  61. package/dist/src/server/restart-policy.d.ts +2 -2
  62. package/dist/src/server/restart-policy.js +3 -1
  63. package/dist/src/server/role-template-store.d.ts +1 -0
  64. package/dist/src/server/role-template-store.js +11 -1
  65. package/dist/src/server/route-types.d.ts +43 -0
  66. package/dist/src/server/routes-runtime.js +2 -1
  67. package/dist/src/server/routes-settings.js +76 -0
  68. package/dist/src/server/routes-team.js +221 -2
  69. package/dist/src/server/routes-workflow-schedules.d.ts +2 -0
  70. package/dist/src/server/routes-workflow-schedules.js +58 -0
  71. package/dist/src/server/routes-workflows.d.ts +2 -0
  72. package/dist/src/server/routes-workflows.js +83 -0
  73. package/dist/src/server/routes.js +4 -0
  74. package/dist/src/server/runtime-restart-policy.d.ts +3 -1
  75. package/dist/src/server/runtime-restart-policy.js +3 -1
  76. package/dist/src/server/runtime-store-contract.d.ts +122 -0
  77. package/dist/src/server/runtime-store-contract.js +1 -0
  78. package/dist/src/server/runtime-store-helpers.d.ts +9 -0
  79. package/dist/src/server/runtime-store-helpers.js +101 -2
  80. package/dist/src/server/runtime-store-workflows.d.ts +6 -0
  81. package/dist/src/server/runtime-store-workflows.js +100 -0
  82. package/dist/src/server/runtime-store.d.ts +3 -70
  83. package/dist/src/server/runtime-store.js +70 -4
  84. package/dist/src/server/session-capture-claude.d.ts +23 -0
  85. package/dist/src/server/session-capture-claude.js +24 -1
  86. package/dist/src/server/session-capture-codex.d.ts +3 -3
  87. package/dist/src/server/session-capture-codex.js +9 -7
  88. package/dist/src/server/session-capture-gemini.d.ts +1 -1
  89. package/dist/src/server/session-capture-gemini.js +6 -3
  90. package/dist/src/server/session-capture-opencode.d.ts +18 -0
  91. package/dist/src/server/session-capture-opencode.js +27 -2
  92. package/dist/src/server/settings-store.d.ts +3 -0
  93. package/dist/src/server/settings-store.js +1 -0
  94. package/dist/src/server/sqlite-schema-v19.d.ts +2 -0
  95. package/dist/src/server/sqlite-schema-v19.js +17 -0
  96. package/dist/src/server/sqlite-schema-v20.d.ts +2 -0
  97. package/dist/src/server/sqlite-schema-v20.js +20 -0
  98. package/dist/src/server/sqlite-schema-v21.d.ts +2 -0
  99. package/dist/src/server/sqlite-schema-v21.js +20 -0
  100. package/dist/src/server/sqlite-schema.d.ts +1 -1
  101. package/dist/src/server/sqlite-schema.js +97 -1
  102. package/dist/src/server/startup-command-parser.d.ts +15 -0
  103. package/dist/src/server/startup-command-parser.js +33 -2
  104. package/dist/src/server/system-message.d.ts +7 -0
  105. package/dist/src/server/system-message.js +8 -1
  106. package/dist/src/server/tasks-file-watcher.d.ts +39 -1
  107. package/dist/src/server/tasks-file-watcher.js +155 -25
  108. package/dist/src/server/tasks-file.d.ts +2 -1
  109. package/dist/src/server/tasks-file.js +32 -9
  110. package/dist/src/server/tasks-websocket-server.js +13 -14
  111. package/dist/src/server/team-authz.d.ts +1 -1
  112. package/dist/src/server/team-authz.js +9 -1
  113. package/dist/src/server/team-autostaff.d.ts +16 -0
  114. package/dist/src/server/team-autostaff.js +16 -0
  115. package/dist/src/server/team-list-serializer.d.ts +1 -1
  116. package/dist/src/server/team-list-serializer.js +3 -1
  117. package/dist/src/server/team-operations.d.ts +20 -2
  118. package/dist/src/server/team-operations.js +160 -14
  119. package/dist/src/server/terminal-input-profile.js +2 -8
  120. package/dist/src/server/terminal-protocol.js +9 -3
  121. package/dist/src/server/terminal-stream-hub.js +16 -10
  122. package/dist/src/server/terminal-ws-server.js +36 -16
  123. package/dist/src/server/websocket-upgrade-safety.d.ts +10 -0
  124. package/dist/src/server/websocket-upgrade-safety.js +35 -0
  125. package/dist/src/server/windows-command-line.d.ts +3 -0
  126. package/dist/src/server/windows-command-line.js +9 -0
  127. package/dist/src/server/windows-filename.d.ts +2 -0
  128. package/dist/src/server/windows-filename.js +33 -0
  129. package/dist/src/server/workflow-cli-policy.d.ts +60 -0
  130. package/dist/src/server/workflow-cli-policy.js +110 -0
  131. package/dist/src/server/workflow-dispatch-awaiter.d.ts +12 -0
  132. package/dist/src/server/workflow-dispatch-awaiter.js +80 -0
  133. package/dist/src/server/workflow-feature.d.ts +15 -0
  134. package/dist/src/server/workflow-feature.js +15 -0
  135. package/dist/src/server/workflow-http-serializers.d.ts +64 -0
  136. package/dist/src/server/workflow-http-serializers.js +58 -0
  137. package/dist/src/server/workflow-run-log-store.d.ts +19 -0
  138. package/dist/src/server/workflow-run-log-store.js +45 -0
  139. package/dist/src/server/workflow-run-store.d.ts +50 -0
  140. package/dist/src/server/workflow-run-store.js +103 -0
  141. package/dist/src/server/workflow-runner.d.ts +147 -0
  142. package/dist/src/server/workflow-runner.js +401 -0
  143. package/dist/src/server/workflow-schedule-create.d.ts +14 -0
  144. package/dist/src/server/workflow-schedule-create.js +41 -0
  145. package/dist/src/server/workflow-schedule-store.d.ts +43 -0
  146. package/dist/src/server/workflow-schedule-store.js +112 -0
  147. package/dist/src/server/workflow-scheduler.d.ts +36 -0
  148. package/dist/src/server/workflow-scheduler.js +97 -0
  149. package/dist/src/server/workflow-script-loader.d.ts +34 -0
  150. package/dist/src/server/workflow-script-loader.js +106 -0
  151. package/dist/src/server/workspace-path-validation.js +16 -4
  152. package/dist/src/server/workspace-shell-runtime.d.ts +5 -0
  153. package/dist/src/server/workspace-shell-runtime.js +24 -2
  154. package/dist/src/server/workspace-store-contract.d.ts +4 -1
  155. package/dist/src/server/workspace-store-hydration.js +23 -7
  156. package/dist/src/server/workspace-store-mutations.js +2 -5
  157. package/dist/src/server/workspace-store-support.d.ts +4 -0
  158. package/dist/src/server/workspace-store-support.js +13 -1
  159. package/dist/src/server/workspace-store.js +38 -4
  160. package/dist/src/shared/types.d.ts +16 -1
  161. package/package.json +4 -2
  162. package/web/dist/assets/{AddWorkerDialog-DmkDOdp6.js → AddWorkerDialog-CcC-7kgG.js} +2 -2
  163. package/web/dist/assets/AddWorkspaceDialog-BDpOTfmt.js +1 -0
  164. package/web/dist/assets/{FirstRunWizard-SAd1wsH4.js → FirstRunWizard-BYX_ocQn.js} +1 -1
  165. package/web/dist/assets/{MarketplaceDrawer-B_8aG2uT.js → MarketplaceDrawer-DUxSk7db.js} +1 -1
  166. package/web/dist/assets/WhatsNewDialog-B_RlCXcV.js +1 -0
  167. package/web/dist/assets/WorkerModal-D9-7YfZZ.js +1 -0
  168. package/web/dist/assets/WorkspaceTaskDrawer-BCKoF7qc.js +1 -0
  169. package/web/dist/assets/{WorkspaceTerminalPanels-BReWh1YL.js → WorkspaceTerminalPanels-Dq8y91t2.js} +1 -1
  170. package/web/dist/assets/index-BiOvKIVw.css +1 -0
  171. package/web/dist/assets/index-DMRUklT3.js +73 -0
  172. package/web/dist/assets/path-join-7MR1s7b1.js +1 -0
  173. package/web/dist/index.html +2 -2
  174. package/web/dist/sw.js +1 -1
  175. package/web/dist/assets/AddWorkspaceDialog-BsVnH3Xe.js +0 -1
  176. package/web/dist/assets/WorkerModal-CQmjiPme.js +0 -1
  177. package/web/dist/assets/WorkspaceTaskDrawer-B0DmCWcV.js +0 -1
  178. package/web/dist/assets/chevron-right-CtLjVEl7.js +0 -1
  179. package/web/dist/assets/index-BEsTmfrO.css +0 -1
  180. package/web/dist/assets/index-Cn8X3get.js +0 -76
@@ -0,0 +1,401 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { cpus } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { assertWindowsSafeFilename } from './windows-filename.js';
5
+ import { resolveWorkflowCli } from './workflow-cli-policy.js';
6
+ import { loadWorkflowScriptFile, loadWorkflowScriptSource } from './workflow-script-loader.js';
7
+ import { getWorkflowAgentId } from './workspace-store-support.js';
8
+ // TIER 2 #2 + #11 — runtime caps. Match Claude Code's documented caps
9
+ // (1000 lifetime calls, min(16, cores-2) concurrent) so scripts copied
10
+ // from CC docs behave the same way. The duration default is Hive's own
11
+ // choice; CC docs don't specify one but workflows that run >60min are
12
+ // almost always a runaway loop in practice.
13
+ const DEFAULT_MAX_AGENTS_PER_RUN = 1000;
14
+ const DEFAULT_MAX_DURATION_MS = 60 * 60 * 1000;
15
+ const DEFAULT_MAX_CONCURRENT_AGENTS = Math.min(16, Math.max(2, cpus().length - 2));
16
+ const BUILT_IN_WORKER_ROLES = new Set(['coder', 'reviewer', 'tester', 'custom']);
17
+ const isBuiltInWorkerRole = (value) => BUILT_IN_WORKER_ROLES.has(value);
18
+ const buildModelArgs = (_cli, model) => {
19
+ if (!model?.trim())
20
+ return [];
21
+ /* All four supported CLIs (claude / codex / opencode / gemini) accept
22
+ `--model <id>` as a positional flag. Keeping the mapping centralised
23
+ here so future CLI quirks (e.g. opencode wanting `-m` instead) can
24
+ be patched without touching the runner body. */
25
+ return ['--model', model];
26
+ };
27
+ const toNestedWorkflowFilename = (scriptName) => {
28
+ const trimmed = scriptName.trim();
29
+ if (!trimmed)
30
+ throw new Error('workflow(scriptName): scriptName must be a non-empty string');
31
+ const filename = trimmed.toLowerCase().endsWith('.ts') ? trimmed : `${trimmed}.ts`;
32
+ assertWindowsSafeFilename(filename);
33
+ return filename;
34
+ };
35
+ export const createWorkflowRunner = (deps) => {
36
+ const { store, workflowRunStore, awaiter, dispatchPort, resolveWorkspacePath, roleTemplateResolver, logStore, getWorkflowCliPolicy, } = deps;
37
+ const stoppedRuns = new Set();
38
+ // In-memory map: runId → triggering agent. Lost on restart; the spec already
39
+ // doesn't auto-resume interrupted runs, so this is consistent.
40
+ const triggeringAgentByRun = new Map();
41
+ const executeWorkflow = async (run, loaded, args, hivePort) => {
42
+ const workspaceId = run.workspaceId;
43
+ const workflowAgentId = getWorkflowAgentId(workspaceId);
44
+ let stepCounter = 0;
45
+ const spawnedWorkers = [];
46
+ // Read the CLI policy once per run so a 1000-way fan-out doesn't hit the
47
+ // app_state table per agent() call.
48
+ const cliPolicy = getWorkflowCliPolicy();
49
+ // TIER 2 #2 + #11 — runtime caps. Per-run agent ceiling, per-call
50
+ // concurrency throttle, and a hard wall-clock budget. meta can
51
+ // override the defaults per script. The budget timer arms here and
52
+ // gets cleared in the finally branch so a fast-completing run
53
+ // doesn't leave a dangling timer in the event loop.
54
+ const maxAgents = typeof loaded.meta.maxAgentCalls === 'number' && loaded.meta.maxAgentCalls > 0
55
+ ? loaded.meta.maxAgentCalls
56
+ : DEFAULT_MAX_AGENTS_PER_RUN;
57
+ const maxDurationMs = typeof loaded.meta.maxDurationMs === 'number' && loaded.meta.maxDurationMs > 0
58
+ ? loaded.meta.maxDurationMs
59
+ : DEFAULT_MAX_DURATION_MS;
60
+ // Simple semaphore — a FIFO queue of resolvers. Each acquire() returns
61
+ // a release() callback that pumps the next waiter. Default
62
+ // concurrency is min(16, cores-2), matching CC's documented cap;
63
+ // intentionally shared across all parallel/pipeline calls in this
64
+ // run so nested fan-outs don't blow through the cap.
65
+ const concurrencyLimit = DEFAULT_MAX_CONCURRENT_AGENTS;
66
+ let inFlight = 0;
67
+ const waitQueue = [];
68
+ const acquireSlot = async () => {
69
+ if (inFlight < concurrencyLimit) {
70
+ inFlight++;
71
+ return () => {
72
+ inFlight--;
73
+ waitQueue.shift()?.();
74
+ };
75
+ }
76
+ await new Promise((resolve) => waitQueue.push(resolve));
77
+ inFlight++;
78
+ return () => {
79
+ inFlight--;
80
+ waitQueue.shift()?.();
81
+ };
82
+ };
83
+ // R1/R2 auto-rounding: a phase('Find') called twice in the same run gets
84
+ // rendered as "Find" then "Find R2" etc. Mirrors Claude Code's /workflows
85
+ // view — keeps the UI readable when a workflow loops phases.
86
+ const phaseEntryCount = new Map();
87
+ let currentPhaseTitle = null;
88
+ const phase = (title) => {
89
+ const trimmed = (title ?? '').toString().trim() || 'default';
90
+ const next = (phaseEntryCount.get(trimmed) ?? 0) + 1;
91
+ phaseEntryCount.set(trimmed, next);
92
+ currentPhaseTitle = next > 1 ? `${trimmed} R${next}` : trimmed;
93
+ workflowRunStore.updateRun(run.id, { phase: currentPhaseTitle });
94
+ };
95
+ const agent = async (prompt, opts = {}) => {
96
+ // TIER 2 #11 — hard ceiling so a runaway `while (true) await agent()`
97
+ // can't spawn unbounded PTY subprocesses. The check is BEFORE the
98
+ // step counter increments so the error message names the cap, not
99
+ // the over-cap step.
100
+ if (stepCounter >= maxAgents) {
101
+ throw new Error(`Workflow agent cap exceeded: ${maxAgents} calls (set meta.maxAgentCalls to raise)`);
102
+ }
103
+ const myStep = ++stepCounter;
104
+ // TIER 2 #4 — resolve agentType. If it's a built-in WorkerRole
105
+ // (coder/reviewer/tester/custom) we honour it directly with the
106
+ // CLI default. Otherwise look up a workspace custom role template
107
+ // by name: on hit we clone its command + args; on miss we throw a
108
+ // clear error (silent fallback to 'coder' would mask typos and
109
+ // make the Hive-distinctive custom-role library invisible).
110
+ const requestedType = opts.agentType ?? 'coder';
111
+ let role;
112
+ let command;
113
+ let templateArgs = [];
114
+ if (typeof requestedType === 'string' && !isBuiltInWorkerRole(requestedType)) {
115
+ const template = roleTemplateResolver.findByName(requestedType);
116
+ if (!template) {
117
+ throw new Error(`Workflow agentType '${requestedType}' is not a built-in role (coder/reviewer/tester/custom) and no matching role template exists in this workspace. ` +
118
+ `Create one via Add Worker → custom role, or use a built-in role.`);
119
+ }
120
+ /* Templates can carry roleType='orchestrator' for the system-level
121
+ Orchestrator template; that's not a valid workflow worker role,
122
+ so we collapse it to 'custom'. Anything else maps through as-is. */
123
+ role = template.roleType === 'orchestrator' ? 'custom' : template.roleType;
124
+ command = resolveWorkflowCli({
125
+ ...(opts.cli !== undefined ? { requestedCli: opts.cli } : {}),
126
+ isCustomTemplate: true,
127
+ templateDefaultCommand: template.defaultCommand,
128
+ policy: cliPolicy,
129
+ });
130
+ templateArgs = template.defaultArgs;
131
+ }
132
+ else {
133
+ role = requestedType;
134
+ command = resolveWorkflowCli({
135
+ ...(opts.cli !== undefined ? { requestedCli: opts.cli } : {}),
136
+ isCustomTemplate: false,
137
+ policy: cliPolicy,
138
+ });
139
+ }
140
+ const name = opts.label ?? `${requestedType}-${myStep}-${randomUUID()}`;
141
+ /* Model flag goes AFTER the template's own args so an explicit
142
+ opts.model overrides any --model the template baked in. */
143
+ const launchArgs = [...templateArgs, ...buildModelArgs(command, opts.model)];
144
+ // TIER 2 #2 — semaphore. Holding the slot across the full
145
+ // dispatch+await means a parallel(100) fan-out gets paced at
146
+ // min(16, cores-2) concurrent PTYs instead of 100 simultaneous
147
+ // process spawns.
148
+ const releaseSlot = await acquireSlot();
149
+ const worker = store.addWorkerWithLaunch(workspaceId, { name, role, ephemeral: true, spawnedBy: 'workflow' }, { command, args: launchArgs });
150
+ spawnedWorkers.push(worker.id);
151
+ try {
152
+ await store.startAgent(workspaceId, worker.id, { hivePort });
153
+ const dispatch = await store.dispatchTaskByWorkerName(workspaceId, name, prompt, {
154
+ fromAgentId: workflowAgentId,
155
+ hivePort,
156
+ workflowRunId: run.id,
157
+ stepIndex: myStep,
158
+ ...(currentPhaseTitle ? { phase: currentPhaseTitle } : {}),
159
+ label: opts.label ?? name,
160
+ });
161
+ const report = await awaiter.awaitReport(dispatch.id, opts.timeoutMs);
162
+ return report.text;
163
+ }
164
+ finally {
165
+ try {
166
+ store.deleteWorker(workspaceId, worker.id);
167
+ }
168
+ catch {
169
+ /* idempotent — worker may already be gone via cascade or boot cleanup */
170
+ }
171
+ const idx = spawnedWorkers.indexOf(worker.id);
172
+ if (idx !== -1)
173
+ spawnedWorkers.splice(idx, 1);
174
+ releaseSlot();
175
+ }
176
+ };
177
+ // TIER 1 #3 — per-item rejections become null (preserves the
178
+ // documented contract: callers use `.filter(Boolean)` patterns), BUT
179
+ // if the run is being stopped we re-throw so the outer catch handles
180
+ // it as `status='stopped'` instead of silently completing with a
181
+ // result array full of nulls. Per-item failures still get logged so
182
+ // they're not entirely invisible (TIER 2 #3 will pipe these into the
183
+ // run timeline via log()).
184
+ const catchPerItem = (value) => {
185
+ if (stoppedRuns.has(run.id))
186
+ throw value;
187
+ console.warn(`[workflow ${loaded.meta.name}] item failed:`, value);
188
+ return null;
189
+ };
190
+ const parallel = async (thunks) => {
191
+ return Promise.all(thunks.map((thunk) => thunk().catch((err) => catchPerItem(err))));
192
+ };
193
+ const pipeline = async (items, ...stages) => {
194
+ return Promise.all(items.map((item, index) => {
195
+ let chain = Promise.resolve(item);
196
+ for (const stage of stages) {
197
+ chain = chain.then((prev) => stage(prev, item, index));
198
+ }
199
+ return chain.catch((err) => catchPerItem(err));
200
+ }));
201
+ };
202
+ const log = (message) => {
203
+ // TIER 2 #3 — persist + still echo to stdout for server-log
204
+ // visibility. Authors expect `log()` to surface in the Drawer's
205
+ // narrator lane and in the orchestrator's completion reminder.
206
+ // The console.log retains the server-side breadcrumb for ops.
207
+ const text = typeof message === 'string' ? message : String(message);
208
+ try {
209
+ logStore.append(run.id, text);
210
+ }
211
+ catch (error) {
212
+ console.error('[hive] swallowed:workflow.log.append', error);
213
+ }
214
+ console.log(`[workflow ${loaded.meta.name}] ${text}`);
215
+ };
216
+ // Nested workflow: look up a sibling script in the same .hive/workflows/
217
+ // directory and run it through THIS runner instance. The child run gets
218
+ // its own row in workflow_runs and shares the dispatch-await machinery
219
+ // (so its `agent()` calls work the same way the parent's do).
220
+ //
221
+ // TIER 1 #7 — when the parent was fired inline (`team workflow run
222
+ // --stdin`), its scriptPath is the synthetic token `<inline>` /
223
+ // `<inline:name>`. `dirname('<inline>')` collapses to `.` and the
224
+ // child would resolve against the runtime's CWD — wrong directory,
225
+ // reliably broken. Detect the synthetic prefix and fall back to the
226
+ // workspace's `.hive/workflows/` directory, which is the canonical
227
+ // sibling-script location.
228
+ const isSyntheticParentPath = run.scriptPath.startsWith('<inline');
229
+ const workflow = async (scriptName, childArgs) => {
230
+ if (typeof scriptName !== 'string' || !scriptName.trim()) {
231
+ throw new Error('workflow(scriptName): scriptName must be a non-empty string');
232
+ }
233
+ const filename = toNestedWorkflowFilename(scriptName);
234
+ const childPath = isSyntheticParentPath
235
+ ? join(resolveWorkspacePath(workspaceId), '.hive', 'workflows', filename)
236
+ : join(dirname(run.scriptPath), filename);
237
+ return runWorkflow({
238
+ workspaceId,
239
+ scriptPath: childPath,
240
+ hivePort,
241
+ // TIER 2 #5 — stamp the parent run id so the UI can render the
242
+ // nested workflow tree.
243
+ parentRunId: run.id,
244
+ ...(childArgs !== undefined ? { args: childArgs } : {}),
245
+ });
246
+ };
247
+ // TIER 2 #11 — wall-clock budget timer. Triggers stopRun on
248
+ // expiry, which routes through the same path as a user-initiated
249
+ // stop (in-flight awaiters reject; outer catch records 'stopped').
250
+ // The setTimeout is unref'd so a forgotten timer can't keep the
251
+ // Node process alive after shutdown. stopRun is declared further
252
+ // down the file but exists by the time this timer fires, so the
253
+ // closure reference is safe.
254
+ const budgetTimer = setTimeout(() => {
255
+ log(`[hive] maxDurationMs (${maxDurationMs}ms) exceeded — stopping run`);
256
+ stopRun(run.id);
257
+ }, maxDurationMs);
258
+ budgetTimer.unref?.();
259
+ try {
260
+ const factory = new Function(`${loaded.compiledFunctionSource}; return __wf`);
261
+ const fn = factory();
262
+ const returnValue = await fn(agent, parallel, pipeline, phase, log, workflow, args);
263
+ // TIER 1 #2 — if stop was called DURING the run, parallel/pipeline may
264
+ // have caught the cancel rejections (one per in-flight thunk) before
265
+ // the per-item catch could re-throw, e.g. when the user stops AFTER
266
+ // the inner Promise.all has already started but BEFORE any thunk
267
+ // rejects. In that race we'd otherwise write 'completed' with a
268
+ // degraded result (often a list of nulls), which both lies to the UI
269
+ // and lies to the orchestrator's completion notification. Check the
270
+ // marker after fn returns and record the truth instead.
271
+ if (stoppedRuns.has(run.id)) {
272
+ stoppedRuns.delete(run.id);
273
+ workflowRunStore.updateRun(run.id, {
274
+ status: 'stopped',
275
+ finishedAt: Date.now(),
276
+ error: 'Stopped by user',
277
+ });
278
+ }
279
+ else {
280
+ stoppedRuns.delete(run.id);
281
+ // M10: capture the script's return value so the UI can render a single
282
+ // canonical "Result" panel and the orchestrator notification can quote
283
+ // it. `undefined` (no explicit return) stays null on the row.
284
+ workflowRunStore.updateRun(run.id, {
285
+ status: 'completed',
286
+ finishedAt: Date.now(),
287
+ ...(returnValue !== undefined ? { result: returnValue } : {}),
288
+ });
289
+ }
290
+ }
291
+ catch (error) {
292
+ const wasStopped = stoppedRuns.has(run.id);
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ workflowRunStore.updateRun(run.id, {
295
+ status: wasStopped ? 'stopped' : 'failed',
296
+ finishedAt: Date.now(),
297
+ error: wasStopped ? 'Stopped by user' : message,
298
+ });
299
+ stoppedRuns.delete(run.id);
300
+ }
301
+ finally {
302
+ clearTimeout(budgetTimer);
303
+ // Belt-and-suspenders: dismiss any ephemeral worker still alive. The
304
+ // per-call try/finally should already have cleaned each one up; this is
305
+ // an idempotent safety net for unexpected paths.
306
+ for (const workerId of spawnedWorkers.splice(0)) {
307
+ try {
308
+ store.deleteWorker(workspaceId, workerId);
309
+ }
310
+ catch {
311
+ /* swallow */
312
+ }
313
+ }
314
+ // Notify the triggering agent (orchestrator) that the run reached a
315
+ // terminal state. Mirrors Claude Code's <task-notification> envelope.
316
+ const triggeredByAgentId = triggeringAgentByRun.get(run.id);
317
+ triggeringAgentByRun.delete(run.id);
318
+ if (triggeredByAgentId && deps.onRunFinished) {
319
+ const finalRecord = workflowRunStore.getRun(run.id);
320
+ if (finalRecord) {
321
+ try {
322
+ deps.onRunFinished({ runId: run.id, triggeredByAgentId, finalRecord });
323
+ }
324
+ catch (error) {
325
+ console.error('[hive] swallowed:workflow.onRunFinished', error);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ };
331
+ const buildCreateInput = (input, loaded) => {
332
+ const createInput = {
333
+ workspaceId: input.workspaceId,
334
+ scriptPath: input.scriptPath,
335
+ name: loaded.meta.name,
336
+ scriptHash: loaded.scriptHash,
337
+ };
338
+ if (input.args !== undefined)
339
+ createInput.args = input.args;
340
+ if (input.parentRunId !== undefined)
341
+ createInput.parentRunId = input.parentRunId;
342
+ return createInput;
343
+ };
344
+ const rememberTrigger = (runId, triggeredByAgentId) => {
345
+ if (triggeredByAgentId)
346
+ triggeringAgentByRun.set(runId, triggeredByAgentId);
347
+ };
348
+ const runWorkflow = async (input) => {
349
+ const loaded = await loadWorkflowScriptFile(input.scriptPath);
350
+ const run = workflowRunStore.createRun(buildCreateInput(input, loaded));
351
+ rememberTrigger(run.id, input.triggeredByAgentId);
352
+ await executeWorkflow(run, loaded, input.args, input.hivePort);
353
+ const finalized = workflowRunStore.getRun(run.id);
354
+ if (!finalized)
355
+ throw new Error(`workflow run vanished mid-flight: ${run.id}`);
356
+ return finalized;
357
+ };
358
+ const startWorkflow = async (input) => {
359
+ const loaded = await loadWorkflowScriptFile(input.scriptPath);
360
+ const run = workflowRunStore.createRun(buildCreateInput(input, loaded));
361
+ rememberTrigger(run.id, input.triggeredByAgentId);
362
+ queueMicrotask(() => {
363
+ executeWorkflow(run, loaded, input.args, input.hivePort).catch((error) => {
364
+ console.error('[hive] swallowed:workflow.background', error);
365
+ });
366
+ });
367
+ return run;
368
+ };
369
+ const startWorkflowInline = async (input) => {
370
+ const scriptPath = input.scriptPath ?? '<inline>';
371
+ const loaded = await loadWorkflowScriptSource(input.source, scriptPath);
372
+ const run = workflowRunStore.createRun(buildCreateInput({ ...input, scriptPath }, loaded));
373
+ rememberTrigger(run.id, input.triggeredByAgentId);
374
+ queueMicrotask(() => {
375
+ executeWorkflow(run, loaded, input.args, input.hivePort).catch((error) => {
376
+ console.error('[hive] swallowed:workflow.background', error);
377
+ });
378
+ });
379
+ return run;
380
+ };
381
+ const stopRun = (runId) => {
382
+ const current = workflowRunStore.getRun(runId);
383
+ if (!current || current.status !== 'running')
384
+ return false;
385
+ stoppedRuns.add(runId);
386
+ // Cancel every open workflow dispatch tied to this run; this rejects the
387
+ // runner's pending `awaitReport` promises, which propagates up the
388
+ // executeWorkflow try → its catch sets status='stopped'.
389
+ for (const dispatchId of dispatchPort.listOpenDispatchIdsForRun(runId)) {
390
+ awaiter.notifyCancel(dispatchId, 'Stopped by user');
391
+ }
392
+ // If the script had no in-flight agent() call when stop was requested,
393
+ // the workflow may simply continue to its natural end. We still record
394
+ // the intent so a subsequent agent() call rejects immediately. To make
395
+ // single-step or no-agent() scripts also reflect 'stopped', mark the row
396
+ // synchronously as a hint to UI — the runner will overwrite to 'stopped'
397
+ // (or 'completed' if it really did finish) when executeWorkflow exits.
398
+ return true;
399
+ };
400
+ return { runWorkflow, startWorkflow, startWorkflowInline, stopRun };
401
+ };
@@ -0,0 +1,14 @@
1
+ import type { createWorkflowScheduleStore } from './workflow-schedule-store.js';
2
+ type ScheduleStore = ReturnType<typeof createWorkflowScheduleStore>;
3
+ export interface PersistWorkflowScheduleInput {
4
+ workspacePath: string;
5
+ scheduleStore: ScheduleStore;
6
+ workspaceId: string;
7
+ source: string;
8
+ name: string;
9
+ cron: string;
10
+ nextRunAt: number;
11
+ args?: unknown;
12
+ }
13
+ export declare const persistWorkflowSchedule: (input: PersistWorkflowScheduleInput) => Promise<import("./workflow-schedule-store.js").WorkflowScheduleRecord>;
14
+ export {};
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { BadRequestError } from './http-errors.js';
5
+ import { getWindowsFilenameError } from './windows-filename.js';
6
+ // Agent-scheduled workflows persist their inline source to a file under
7
+ // <workspace>/.hive/workflows/ so the existing file-based scheduler can load
8
+ // it at fire time — no orchestrator is in the loop when cron fires, so the
9
+ // source must outlive the request. The file is agent-managed and is
10
+ // intentionally NOT surfaced in the UI (workflows are agent-authored, not a
11
+ // human script library).
12
+ const toScriptFilename = (nameRaw) => {
13
+ const slug = nameRaw
14
+ .trim()
15
+ .toLowerCase()
16
+ .replace(/\.ts$/, '')
17
+ .replace(/[^a-z0-9._-]+/g, '-')
18
+ .replace(/^-+|-+$/g, '');
19
+ if (!slug) {
20
+ throw new BadRequestError('schedule name must contain at least one alphanumeric character');
21
+ }
22
+ const windowsNameProbe = `${slug}.ts`;
23
+ const filenameError = getWindowsFilenameError(windowsNameProbe);
24
+ if (filenameError)
25
+ throw new BadRequestError(`Invalid workflow schedule name: ${filenameError}`);
26
+ return `${slug}-${randomUUID()}.ts`;
27
+ };
28
+ export const persistWorkflowSchedule = async (input) => {
29
+ const filename = toScriptFilename(input.name);
30
+ const dir = join(input.workspacePath, '.hive', 'workflows');
31
+ await mkdir(dir, { recursive: true });
32
+ const scriptPath = join(dir, filename);
33
+ await writeFile(scriptPath, input.source, 'utf8');
34
+ return input.scheduleStore.create({
35
+ workspaceId: input.workspaceId,
36
+ scriptPath,
37
+ cron: input.cron,
38
+ nextRunAt: input.nextRunAt,
39
+ ...(input.args !== undefined ? { args: input.args } : {}),
40
+ });
41
+ };
@@ -0,0 +1,43 @@
1
+ import type { Database } from 'better-sqlite3';
2
+ export interface WorkflowScheduleRecord {
3
+ id: string;
4
+ workspaceId: string;
5
+ scriptPath: string;
6
+ cron: string;
7
+ args: unknown;
8
+ enabled: boolean;
9
+ lastRunAt: number | null;
10
+ nextRunAt: number;
11
+ createdAt: number;
12
+ updatedAt: number;
13
+ }
14
+ interface CreateInput {
15
+ workspaceId: string;
16
+ scriptPath: string;
17
+ cron: string;
18
+ nextRunAt: number;
19
+ args?: unknown;
20
+ enabled?: boolean;
21
+ }
22
+ interface UpdateInput {
23
+ cron?: string;
24
+ args?: unknown;
25
+ enabled?: boolean;
26
+ lastRunAt?: number;
27
+ nextRunAt?: number;
28
+ }
29
+ export declare const createWorkflowScheduleStore: (db: Database) => {
30
+ create: (input: CreateInput) => WorkflowScheduleRecord;
31
+ update: (id: string, input: UpdateInput) => void;
32
+ get: (id: string) => WorkflowScheduleRecord | undefined;
33
+ listForWorkspace: (workspaceId: string) => WorkflowScheduleRecord[];
34
+ listDueSchedules: (now: number) => WorkflowScheduleRecord[];
35
+ deleteSchedule: (id: string) => void;
36
+ claimDueSchedule: (input: {
37
+ id: string;
38
+ expectedNextRunAt: number;
39
+ newNextRunAt: number;
40
+ lastRunAt: number;
41
+ }) => boolean;
42
+ };
43
+ export {};
@@ -0,0 +1,112 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const parseArgs = (value) => {
3
+ if (!value)
4
+ return null;
5
+ try {
6
+ return JSON.parse(value);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ };
12
+ const toRecord = (row) => ({
13
+ id: row.id,
14
+ workspaceId: row.workspace_id,
15
+ scriptPath: row.script_path,
16
+ cron: row.cron,
17
+ args: parseArgs(row.args),
18
+ enabled: row.enabled === 1,
19
+ lastRunAt: row.last_run_at,
20
+ nextRunAt: row.next_run_at,
21
+ createdAt: row.created_at,
22
+ updatedAt: row.updated_at,
23
+ });
24
+ export const createWorkflowScheduleStore = (db) => {
25
+ const create = (input) => {
26
+ const now = Date.now();
27
+ const record = {
28
+ id: randomUUID(),
29
+ workspaceId: input.workspaceId,
30
+ scriptPath: input.scriptPath,
31
+ cron: input.cron,
32
+ args: input.args ?? null,
33
+ enabled: input.enabled ?? true,
34
+ lastRunAt: null,
35
+ nextRunAt: input.nextRunAt,
36
+ createdAt: now,
37
+ updatedAt: now,
38
+ };
39
+ db.prepare(`INSERT INTO workflow_schedules (
40
+ id, workspace_id, script_path, cron, args, enabled,
41
+ last_run_at, next_run_at, created_at, updated_at
42
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(record.id, record.workspaceId, record.scriptPath, record.cron, record.args === null ? null : JSON.stringify(record.args), record.enabled ? 1 : 0, record.lastRunAt, record.nextRunAt, record.createdAt, record.updatedAt);
43
+ return record;
44
+ };
45
+ const update = (id, input) => {
46
+ const sets = [];
47
+ const values = [];
48
+ if (input.cron !== undefined) {
49
+ sets.push('cron = ?');
50
+ values.push(input.cron);
51
+ }
52
+ if (input.args !== undefined) {
53
+ sets.push('args = ?');
54
+ values.push(input.args === null ? null : JSON.stringify(input.args));
55
+ }
56
+ if (input.enabled !== undefined) {
57
+ sets.push('enabled = ?');
58
+ values.push(input.enabled ? 1 : 0);
59
+ }
60
+ if (input.lastRunAt !== undefined) {
61
+ sets.push('last_run_at = ?');
62
+ values.push(input.lastRunAt);
63
+ }
64
+ if (input.nextRunAt !== undefined) {
65
+ sets.push('next_run_at = ?');
66
+ values.push(input.nextRunAt);
67
+ }
68
+ if (sets.length === 0)
69
+ return;
70
+ sets.push('updated_at = ?');
71
+ values.push(Date.now());
72
+ values.push(id);
73
+ db.prepare(`UPDATE workflow_schedules SET ${sets.join(', ')} WHERE id = ?`).run(...values);
74
+ };
75
+ const get = (id) => {
76
+ const row = db.prepare('SELECT * FROM workflow_schedules WHERE id = ?').get(id);
77
+ return row ? toRecord(row) : undefined;
78
+ };
79
+ const listForWorkspace = (workspaceId) => db
80
+ .prepare('SELECT * FROM workflow_schedules WHERE workspace_id = ? ORDER BY created_at DESC, id DESC')
81
+ .all(workspaceId).map(toRecord);
82
+ // Enabled schedules whose next_run_at has arrived — the scheduler queries
83
+ // this every tick and fires each.
84
+ const listDueSchedules = (now) => db
85
+ .prepare('SELECT * FROM workflow_schedules WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at')
86
+ .all(now).map(toRecord);
87
+ const deleteSchedule = (id) => {
88
+ db.prepare('DELETE FROM workflow_schedules WHERE id = ?').run(id);
89
+ };
90
+ // TIER 1 #5 — compare-and-swap claim. If two scheduler ticks see the same
91
+ // due schedule (overlapping setInterval invocations, slow esbuild
92
+ // transpile in startWorkflow, or future multi-process), only the one
93
+ // whose UPDATE matches the still-original next_run_at wins. Returns
94
+ // true if the caller may proceed to fire the workflow.
95
+ const claimDueSchedule = (input) => {
96
+ const result = db
97
+ .prepare(`UPDATE workflow_schedules
98
+ SET next_run_at = ?, last_run_at = ?, updated_at = ?
99
+ WHERE id = ? AND next_run_at = ?`)
100
+ .run(input.newNextRunAt, input.lastRunAt, Date.now(), input.id, input.expectedNextRunAt);
101
+ return result.changes === 1;
102
+ };
103
+ return {
104
+ create,
105
+ update,
106
+ get,
107
+ listForWorkspace,
108
+ listDueSchedules,
109
+ deleteSchedule,
110
+ claimDueSchedule,
111
+ };
112
+ };
@@ -0,0 +1,36 @@
1
+ import type { WorkflowRunRecord } from './workflow-run-store.js';
2
+ import type { RunWorkflowInput } from './workflow-runner.js';
3
+ import type { createWorkflowScheduleStore } from './workflow-schedule-store.js';
4
+ type ScheduleStore = ReturnType<typeof createWorkflowScheduleStore>;
5
+ export interface WorkflowSchedulerDeps {
6
+ schedules: ScheduleStore;
7
+ startWorkflow: (input: RunWorkflowInput) => Promise<WorkflowRunRecord>;
8
+ /** Defensive guard against orphan schedules (TIER 1 #4). The workspace
9
+ * delete cascade clears workflow_schedules in the same transaction so
10
+ * fresh orphans cannot appear; this port catches any pre-existing or
11
+ * externally-introduced orphan and self-heals by deleting the schedule
12
+ * before it can fire and crash startWorkflow → re-fire next tick. */
13
+ workspaceExists?: (workspaceId: string) => boolean;
14
+ /** HivePort to pass into every fired workflow. Resolved lazily so the
15
+ * scheduler doesn't need to be reconstructed when the runtime listens
16
+ * on a different port (e.g. tests using port 0). */
17
+ getHivePort?: () => string;
18
+ /** Test seam: defaults to cron-parser. Returns next fire time in ms-epoch. */
19
+ computeNextRunAt?: (cron: string, after: Date) => number;
20
+ /** Experimental workflow gate. When provided and returns false, ticks fire
21
+ * nothing (scheduled runs are held, not dropped — they fire once re-enabled).
22
+ * Omitted → no gate (the scheduler unit tests rely on this default). */
23
+ isWorkflowEnabled?: () => boolean;
24
+ }
25
+ export interface WorkflowScheduler {
26
+ /** Run one tick using the given `now` (ms-epoch). For both production and tests. */
27
+ tick: (now?: number) => Promise<void>;
28
+ /** Arm a setInterval that calls tick() every `tickIntervalMs`. Returns void. */
29
+ start: (input?: {
30
+ tickIntervalMs?: number;
31
+ }) => void;
32
+ /** Clear the timer. Idempotent. */
33
+ close: () => void;
34
+ }
35
+ export declare const createWorkflowScheduler: (deps: WorkflowSchedulerDeps) => WorkflowScheduler;
36
+ export {};