@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -2
  3. package/bin/codeflow.mjs +8 -0
  4. package/bin/launcher.mjs +127 -0
  5. package/dist/remote-host.json +1 -0
  6. package/licenses/inter.txt +92 -0
  7. package/licenses/preact.txt +21 -0
  8. package/licenses/signals-core.txt +21 -0
  9. package/licenses/signals.txt +21 -0
  10. package/licenses/typescript-notices.txt +193 -0
  11. package/licenses/typescript.txt +55 -0
  12. package/licenses/uqr.txt +22 -0
  13. package/licenses/xterm-fit.txt +19 -0
  14. package/licenses/xterm.txt +21 -0
  15. package/package.json +34 -4
  16. package/public/assets/browser-7XI52A6V.js +8 -0
  17. package/public/assets/changes-EYFPASEI.js +1 -0
  18. package/public/assets/chunk-7H3ZLTKA.js +2 -0
  19. package/public/assets/chunk-L7TUCIYF.js +1 -0
  20. package/public/assets/chunk-LLMQG23J.js +163 -0
  21. package/public/assets/main-KIRM2OEK.js +1 -0
  22. package/public/assets/settings-QJZVW22Y.js +9 -0
  23. package/public/assets/styles-615EBB20.css +32 -0
  24. package/public/assets/terminal-A2FI2NAX.js +62 -0
  25. package/public/assets.json +1 -0
  26. package/public/bundle-meta.json +1 -0
  27. package/public/docs/.nojekyll +0 -0
  28. package/public/docs/accessibility.html +19 -0
  29. package/public/docs/acp-agents.html +117 -0
  30. package/public/docs/agent-containers.html +79 -0
  31. package/public/docs/api.html +1130 -0
  32. package/public/docs/app.js +342 -0
  33. package/public/docs/application-improvements.html +275 -0
  34. package/public/docs/architecture-explorer.html +19 -0
  35. package/public/docs/architecture.html +149 -0
  36. package/public/docs/auto-team.html +142 -0
  37. package/public/docs/automations.html +109 -0
  38. package/public/docs/checkpoint-review.html +37 -0
  39. package/public/docs/code-hosts.html +35 -0
  40. package/public/docs/code-intelligence.html +35 -0
  41. package/public/docs/configuration.html +346 -0
  42. package/public/docs/context-inspection.html +23 -0
  43. package/public/docs/coordinated-changes.html +28 -0
  44. package/public/docs/deferred-runs.html +25 -0
  45. package/public/docs/dependency-discovery.html +19 -0
  46. package/public/docs/documentation.html +84 -0
  47. package/public/docs/dynamic-teams-evaluation.html +174 -0
  48. package/public/docs/dynamic-teams-implementation-plan.html +882 -0
  49. package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
  50. package/public/docs/dynamic-teams-plan-review-0.html +204 -0
  51. package/public/docs/extensions.html +42 -0
  52. package/public/docs/external-sources.html +96 -0
  53. package/public/docs/filesystem-policy.html +26 -0
  54. package/public/docs/filtered-tasks.html +44 -0
  55. package/public/docs/font-license.txt +92 -0
  56. package/public/docs/getting-started.html +118 -0
  57. package/public/docs/github-review-threads.html +26 -0
  58. package/public/docs/github.html +157 -0
  59. package/public/docs/help.html +69 -0
  60. package/public/docs/index.html +77 -0
  61. package/public/docs/installation.html +74 -0
  62. package/public/docs/launch-profiles.html +48 -0
  63. package/public/docs/maintenance.html +60 -0
  64. package/public/docs/memory-claims.html +31 -0
  65. package/public/docs/memory-storage.html +58 -0
  66. package/public/docs/notifications.html +39 -0
  67. package/public/docs/patterns.html +195 -0
  68. package/public/docs/performance-results.html +210 -0
  69. package/public/docs/performance.html +166 -0
  70. package/public/docs/project-conventions.html +22 -0
  71. package/public/docs/project-first-navigation-plan.html +587 -0
  72. package/public/docs/project-teams-implementation-plan.html +772 -0
  73. package/public/docs/project-teams.html +99 -0
  74. package/public/docs/provider-compatibility.html +20 -0
  75. package/public/docs/releasing.html +61 -0
  76. package/public/docs/remote-workers.html +64 -0
  77. package/public/docs/review-inbox.html +26 -0
  78. package/public/docs/run-storage.html +23 -0
  79. package/public/docs/runs.html +141 -0
  80. package/public/docs/search.json +1 -0
  81. package/public/docs/security.html +133 -0
  82. package/public/docs/shared-projects.html +31 -0
  83. package/public/docs/ssh-projects.html +60 -0
  84. package/public/docs/style.css +768 -0
  85. package/public/docs/task-reruns.html +30 -0
  86. package/public/docs/team-collaboration.html +379 -0
  87. package/public/docs/verification.html +22 -0
  88. package/public/docs/vscode.html +53 -0
  89. package/public/docs/worker-permission-verification.html +122 -0
  90. package/public/index.html +17 -0
  91. package/scripts/check-node.mjs +7 -0
  92. package/scripts/codeflow.mjs +55 -0
  93. package/scripts/pty.py +273 -0
  94. package/src/acp-connections.js +110 -0
  95. package/src/adapters/acp-presets.js +37 -0
  96. package/src/adapters/acp-types.js +0 -0
  97. package/src/adapters/acp.js +328 -0
  98. package/src/adapters/agy-auth.js +67 -0
  99. package/src/adapters/agy-isolation.js +223 -0
  100. package/src/adapters/agy.js +297 -0
  101. package/src/adapters/claude.js +220 -0
  102. package/src/adapters/codex-app-server.js +518 -0
  103. package/src/adapters/codex.js +299 -0
  104. package/src/adapters/errors.js +57 -0
  105. package/src/adapters/index.js +91 -0
  106. package/src/adapters/managed-permissions.js +37 -0
  107. package/src/adapters/model-discovery.js +100 -0
  108. package/src/adapters/process.js +418 -0
  109. package/src/adapters/team-tools.js +102 -0
  110. package/src/adapters/types.js +0 -0
  111. package/src/agent-container.js +155 -0
  112. package/src/approvals.js +121 -0
  113. package/src/architecture.js +456 -0
  114. package/src/assets.js +44 -0
  115. package/src/automation.js +901 -0
  116. package/src/certificate-renewal.js +75 -0
  117. package/src/chat-handoff.js +55 -0
  118. package/src/checkpoint-review.js +72 -0
  119. package/src/checks.js +303 -0
  120. package/src/code-host-git.js +102 -0
  121. package/src/code-hosts-api.js +99 -0
  122. package/src/code-hosts.js +482 -0
  123. package/src/code-intelligence-worker.mjs +452 -0
  124. package/src/code-intelligence.js +141 -0
  125. package/src/collab/api.js +365 -0
  126. package/src/collab/checkpoints.js +198 -0
  127. package/src/collab/connections.js +139 -0
  128. package/src/collab/host-execution.js +347 -0
  129. package/src/collab/hub-client.js +210 -0
  130. package/src/collab/link-api.js +229 -0
  131. package/src/collab/link.js +595 -0
  132. package/src/collab/model.js +496 -0
  133. package/src/collab/runner.js +1014 -0
  134. package/src/collab/store.js +1452 -0
  135. package/src/collab/terminals.js +361 -0
  136. package/src/collaboration-reviews.js +116 -0
  137. package/src/config.js +48 -0
  138. package/src/context-budget.js +14 -0
  139. package/src/context-inspection.js +204 -0
  140. package/src/context.js +154 -0
  141. package/src/conventions.js +283 -0
  142. package/src/coordinated-api.js +247 -0
  143. package/src/deferred-launch.js +82 -0
  144. package/src/deferred-runs.js +515 -0
  145. package/src/dependency-health.js +35 -0
  146. package/src/diff.js +43 -0
  147. package/src/docs-help.js +316 -0
  148. package/src/doctor.js +232 -0
  149. package/src/engine.js +4366 -0
  150. package/src/env.js +44 -0
  151. package/src/experience.js +50 -0
  152. package/src/extensions.js +144 -0
  153. package/src/filesystem-access.js +107 -0
  154. package/src/filesystem-policy.js +238 -0
  155. package/src/filtered-task-limits.js +93 -0
  156. package/src/filtered-tasks-api.js +53 -0
  157. package/src/filtered-tasks.js +576 -0
  158. package/src/fs.js +57 -0
  159. package/src/git.js +626 -0
  160. package/src/github-review-threads.js +181 -0
  161. package/src/github.js +538 -0
  162. package/src/http.js +69 -0
  163. package/src/improve-settings.js +38 -0
  164. package/src/improve.js +187 -0
  165. package/src/integrations.js +373 -0
  166. package/src/integrity.js +259 -0
  167. package/src/journey-api.js +77 -0
  168. package/src/journey-replay.js +65 -0
  169. package/src/journey-store.js +97 -0
  170. package/src/journeys.js +60 -0
  171. package/src/json.js +81 -0
  172. package/src/launch-profiles.js +292 -0
  173. package/src/learnings.js +80 -0
  174. package/src/log.js +39 -0
  175. package/src/maintenance.js +335 -0
  176. package/src/memory-analysis.js +182 -0
  177. package/src/memory-claims.js +153 -0
  178. package/src/memory-sqlite.js +236 -0
  179. package/src/memory.js +552 -0
  180. package/src/merge-validation.js +58 -0
  181. package/src/model-catalog.js +238 -0
  182. package/src/model-research.js +91 -0
  183. package/src/notes.js +176 -0
  184. package/src/notifications.js +413 -0
  185. package/src/patterns/auto-team-model.js +212 -0
  186. package/src/patterns/auto-team.js +659 -0
  187. package/src/patterns/council.js +199 -0
  188. package/src/patterns/debate.js +188 -0
  189. package/src/patterns/findings.js +141 -0
  190. package/src/patterns/index.js +19 -0
  191. package/src/patterns/loop.js +158 -0
  192. package/src/patterns/pipeline.js +125 -0
  193. package/src/patterns/prompts.js +181 -0
  194. package/src/patterns/race.js +147 -0
  195. package/src/patterns/schemas.js +40 -0
  196. package/src/patterns/solo.js +49 -0
  197. package/src/patterns/supervisor.js +235 -0
  198. package/src/patterns/task-draft.js +29 -0
  199. package/src/patterns/tdd.js +231 -0
  200. package/src/patterns/team-strategies.js +67 -0
  201. package/src/performance.js +43 -0
  202. package/src/persistence-status.js +35 -0
  203. package/src/phone-sessions.js +96 -0
  204. package/src/preview-overlay.js +383 -0
  205. package/src/preview.js +219 -0
  206. package/src/privacy.js +23 -0
  207. package/src/project-report.js +22 -0
  208. package/src/project-team-model.js +178 -0
  209. package/src/project-team-policy.js +42 -0
  210. package/src/project-team-store.js +213 -0
  211. package/src/project-teams-api.js +36 -0
  212. package/src/project-teams.js +551 -0
  213. package/src/projects.js +286 -0
  214. package/src/provider-eligibility.js +9 -0
  215. package/src/readiness.js +98 -0
  216. package/src/remote-tls.js +66 -0
  217. package/src/remote-workers/bundles.js +198 -0
  218. package/src/remote-workers/client.js +332 -0
  219. package/src/remote-workers/durability.js +37 -0
  220. package/src/remote-workers/protocol.js +68 -0
  221. package/src/remote-workers/stdio.js +138 -0
  222. package/src/remote-workers/worker.js +304 -0
  223. package/src/remote.js +204 -0
  224. package/src/repo-config.js +201 -0
  225. package/src/repo-coordination.js +732 -0
  226. package/src/repo-tree.js +106 -0
  227. package/src/retention-policy.js +466 -0
  228. package/src/review-inbox.js +38 -0
  229. package/src/routing.js +49 -0
  230. package/src/run-analysis.js +111 -0
  231. package/src/run-cold-store.js +231 -0
  232. package/src/run-environment.js +15 -0
  233. package/src/run-history.js +91 -0
  234. package/src/run-log.js +160 -0
  235. package/src/run-permissions.js +110 -0
  236. package/src/run-projection.js +75 -0
  237. package/src/run-recovery.js +53 -0
  238. package/src/run-status.js +54 -0
  239. package/src/serve.js +138 -0
  240. package/src/server.js +2738 -0
  241. package/src/shared-access.js +103 -0
  242. package/src/shared-accounts.js +301 -0
  243. package/src/shared-api.js +239 -0
  244. package/src/shell.js +346 -0
  245. package/src/ship.js +344 -0
  246. package/src/slots.js +74 -0
  247. package/src/snapshot.js +388 -0
  248. package/src/source-context.js +17 -0
  249. package/src/sse-writer.js +70 -0
  250. package/src/ssh-projects/bootstrap.mjs +95 -0
  251. package/src/ssh-projects/config.js +45 -0
  252. package/src/ssh-projects/gateway.js +143 -0
  253. package/src/ssh-projects/manager.js +320 -0
  254. package/src/ssh-projects/team-autostart.mjs +93 -0
  255. package/src/ssh-projects/team-remove.mjs +69 -0
  256. package/src/ssh-projects/team-status.mjs +39 -0
  257. package/src/ssh-projects/transport.js +116 -0
  258. package/src/stats.js +139 -0
  259. package/src/store.js +544 -0
  260. package/src/stream.js +91 -0
  261. package/src/sweeps.js +241 -0
  262. package/src/tailscale.js +101 -0
  263. package/src/task-draft.js +23 -0
  264. package/src/team-evaluation.js +242 -0
  265. package/src/team-mailbox.js +160 -0
  266. package/src/team-memory.js +84 -0
  267. package/src/team-rerun-graph.js +15 -0
  268. package/src/team-reruns.js +130 -0
  269. package/src/team-scheduler.js +121 -0
  270. package/src/team-template-store.js +98 -0
  271. package/src/team-templates.js +117 -0
  272. package/src/team-tool-client.mjs +79 -0
  273. package/src/team-tools.js +176 -0
  274. package/src/teams.js +0 -0
  275. package/src/templates.js +62 -0
  276. package/src/transcript.js +134 -0
  277. package/src/verification-integrity.js +27 -0
  278. package/src/verification.js +183 -0
  279. package/src/watch.js +195 -0
  280. package/src/work-queue.js +47 -0
  281. package/src/workflow-control.js +83 -0
  282. package/src/workspace-api.js +207 -0
  283. package/src/workspace-changes.js +61 -0
  284. package/src/workspace.js +146 -0
@@ -0,0 +1,347 @@
1
+ import { randomBytes, createHash } from "node:crypto";
2
+ import { mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { realpath } from "node:fs/promises";
6
+ import { CODEFLOW_HOME } from "../config.js";
7
+ import { readJson, send } from "../http.js";
8
+ import { logError } from "../log.js";
9
+ import { roleAtLeast } from "../shared-accounts.js";
10
+ import { busyAttempt, CollabError, latestCheckpoint } from "./model.js";
11
+ import { memberActor, ownerActor } from "./store.js";
12
+ import { LocalHub } from "./hub-client.js";
13
+ import { CollabRunner } from "./runner.js";
14
+ const active = (job) => ["queued", "starting", "running", "stopping"].includes(job.state);
15
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
16
+ const integer = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
17
+ const id = (v) => typeof v === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(v);
18
+ const keys = (v, allowed) => {
19
+ if (Object.keys(v).some((key) => !allowed.includes(key))) throw new CollabError("Unknown host execution field");
20
+ };
21
+ function policy(value) {
22
+ if (!object(value)) throw new CollabError("Invalid host execution policy");
23
+ keys(value, ["projectId", "enabled", "userIds", "agent", "trust", "maxMinutes", "maxSteps"]);
24
+ if (!id(value.projectId) || typeof value.enabled !== "boolean" || !Array.isArray(value.userIds) || value.userIds.length > 128 || !value.userIds.every(id) || new Set(value.userIds).size !== value.userIds.length || !["claude", "codex", "agy"].includes(String(value.agent)) || !["safe", "standard"].includes(String(value.trust)) || !integer(value.maxMinutes, 1, 120) || !integer(value.maxSteps, 1, 100)) throw new CollabError("Choose a project, teammates, an installed CLI, permissions, 1\u2013120 minutes and 1\u2013100 steps");
25
+ return structuredClone(value);
26
+ }
27
+ const taskHash = (task) => createHash("sha256").update(JSON.stringify([task.projectId, task.repository, task.title, task.goal, task.acceptance, task.baseRef, task.owner?.userId, task.decisions, latestCheckpoint(task)?.id])).digest("hex");
28
+ function publicJob(job) {
29
+ const { id: id2, taskId, projectId, userId, displayName, state, createdAt, endedAt, runId, attemptId, message } = job;
30
+ return { id: id2, taskId, projectId, userId, displayName, state, createdAt, endedAt, runId, attemptId, message };
31
+ }
32
+ class HostExecution {
33
+ constructor(options) {
34
+ this.options = options;
35
+ this.dir = options.dir ?? join(CODEFLOW_HOME, "host-execution");
36
+ this.file = join(this.dir, "queue.json");
37
+ }
38
+ state = { version: 1, revision: 0, concurrency: 1, policies: [], jobs: [] };
39
+ ready = false;
40
+ closed = false;
41
+ chain = Promise.resolve();
42
+ runners = /* @__PURE__ */ new Map();
43
+ timer;
44
+ dir;
45
+ file;
46
+ serial(fn) {
47
+ const next = this.chain.catch(() => void 0).then(fn);
48
+ this.chain = next;
49
+ return next;
50
+ }
51
+ require() {
52
+ if (!this.ready || this.closed) throw new CollabError("Host execution is unavailable", 503);
53
+ }
54
+ async load() {
55
+ if (this.ready) return;
56
+ try {
57
+ const handle = await open(this.file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
58
+ let value;
59
+ try {
60
+ const stat = await handle.stat();
61
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error("Invalid queue storage");
62
+ value = JSON.parse(await handle.readFile("utf8"));
63
+ } finally {
64
+ await handle.close();
65
+ }
66
+ if (!object(value) || value.version !== 1 || !integer(value.revision, 0, Number.MAX_SAFE_INTEGER) || !integer(value.concurrency, 1, 4) || !Array.isArray(value.policies) || value.policies.length > 200 || !Array.isArray(value.jobs) || value.jobs.length > 250) throw new Error("Invalid queue storage");
67
+ value.policies.forEach(policy);
68
+ for (const j of value.jobs) {
69
+ if (!object(j) || typeof j.id !== "string" || !/^h-[a-f0-9]{16}$/.test(j.id) || !id(j.taskId) || !id(j.projectId) || !id(j.userId) || typeof j.username !== "string" || typeof j.displayName !== "string" || typeof j.repo !== "string" || typeof j.contentHash !== "string" || !/^[a-f0-9]{64}$/.test(j.contentHash) || !integer(j.createdAt, 0, Number.MAX_SAFE_INTEGER) || !["queued", "starting", "running", "stopping", "finished", "failed", "cancelled", "interrupted"].includes(String(j.state)) || j.runId !== void 0 && !id(j.runId) || j.attemptId !== void 0 && (typeof j.attemptId !== "string" || !/^[a-f0-9]{32}$/.test(j.attemptId))) throw new Error("Invalid queue job");
70
+ const p = policy(j.policy);
71
+ if (p.projectId !== j.projectId || !p.userIds.includes(j.userId)) throw new Error("Invalid queue grant");
72
+ }
73
+ if (new Set(value.jobs.map((j) => j.id)).size !== value.jobs.length || new Set(value.policies.map((p) => p.projectId)).size !== value.policies.length) throw new Error("Duplicate queue records");
74
+ this.state = value;
75
+ } catch (error) {
76
+ if (error.code !== "ENOENT") throw error;
77
+ }
78
+ this.ready = true;
79
+ try {
80
+ for (const job of this.state.jobs.filter((j) => active(j) && j.state !== "queued")) {
81
+ const runner = this.runner(job);
82
+ await runner.reconcile();
83
+ const record = await runner.attemptForTask(job.taskId);
84
+ if (record?.runId) {
85
+ job.runId = record.runId;
86
+ job.attemptId = record.attemptId;
87
+ if (job.state !== "stopping") job.state = "running";
88
+ } else {
89
+ job.state = "interrupted";
90
+ job.message = "Start interrupted. Review the task attempt before queuing it again.";
91
+ job.endedAt = Date.now();
92
+ }
93
+ }
94
+ await this.persist();
95
+ } catch (error) {
96
+ this.ready = false;
97
+ await Promise.all([...this.runners.values()].map((runner) => runner.close()));
98
+ throw error;
99
+ }
100
+ }
101
+ start() {
102
+ if (this.timer) return;
103
+ this.timer = setInterval(() => void this.tick().catch((error) => logError("Host execution queue failed", error)), 2e3);
104
+ this.timer.unref();
105
+ }
106
+ async persist() {
107
+ const retained = new Set(this.state.jobs.filter((job) => !active(job)).slice(-200).map((job) => job.id));
108
+ this.state.jobs = this.state.jobs.filter((job) => active(job) || retained.has(job.id));
109
+ const temp = `${this.file}.${randomBytes(8).toString("hex")}.tmp`;
110
+ try {
111
+ await mkdir(dirname(this.file), { recursive: true, mode: 448 });
112
+ const handle = await open(temp, "wx", 384);
113
+ try {
114
+ await handle.writeFile(JSON.stringify(this.state));
115
+ await handle.sync();
116
+ } finally {
117
+ await handle.close();
118
+ }
119
+ await rename(temp, this.file);
120
+ if (process.platform !== "win32") {
121
+ const folder = await open(dirname(this.file), "r");
122
+ try {
123
+ await folder.sync();
124
+ } finally {
125
+ await folder.close();
126
+ }
127
+ }
128
+ } catch (error) {
129
+ this.ready = false;
130
+ for (const job of this.state.jobs) if (active(job) && job.runId) this.options.manager.cancel(job.runId);
131
+ throw error;
132
+ } finally {
133
+ await rm(temp, { force: true }).catch(() => {
134
+ });
135
+ }
136
+ }
137
+ changed(job) {
138
+ this.options.store.hostExecutionChanged(job?.projectId, job?.taskId);
139
+ }
140
+ view(actor) {
141
+ this.require();
142
+ const owner = actor.kind === "owner";
143
+ const member = owner ? void 0 : this.options.members().find((m) => m.userId === actor.userId && m.enabled);
144
+ const visible = (p) => !!this.options.projectRepos(p) && (owner || !!member?.roles[p]);
145
+ const jobs = this.state.jobs.filter((j) => visible(j.projectId)).map(publicJob);
146
+ const policies = this.state.policies.filter((p) => visible(p.projectId));
147
+ return {
148
+ revision: this.state.revision,
149
+ concurrency: this.state.concurrency,
150
+ available: this.options.enabled(),
151
+ policies: owner ? structuredClone(policies) : policies.map((p) => ({ projectId: p.projectId, agent: p.agent, allowed: p.enabled && p.userIds.includes(actor.userId) && roleAtLeast(member?.roles[p.projectId], "contributor") })),
152
+ jobs
153
+ };
154
+ }
155
+ configure(raw) {
156
+ return this.serial(async () => {
157
+ this.require();
158
+ keys(raw, ["revision", "concurrency", "policy"]);
159
+ if (raw.revision !== this.state.revision) throw new CollabError("Host settings changed; reload before saving", 409);
160
+ if (raw.concurrency !== void 0) {
161
+ if (!integer(raw.concurrency, 1, 4)) throw new CollabError("Choose 1\u20134 concurrent host tasks");
162
+ }
163
+ let next;
164
+ if (raw.policy !== void 0) {
165
+ next = policy(raw.policy);
166
+ if (!this.options.projectRepos(next.projectId)) throw new CollabError("Project not found", 404);
167
+ if (!this.state.policies.some((p) => p.projectId === next.projectId) && this.state.policies.length >= 200) throw new CollabError("At most 200 project execution policies may be configured");
168
+ if (next.userIds.some((uid) => !this.options.members().some((m) => m.userId === uid && m.enabled && roleAtLeast(m.roles[next.projectId], "contributor")))) throw new CollabError("Grant execution only to enabled project contributors or maintainers");
169
+ if (next.enabled && !(await this.options.availableAgents()).includes(next.agent)) throw new CollabError("This CLI is not installed on the host");
170
+ }
171
+ if (next) this.state.policies = [...this.state.policies.filter((p) => p.projectId !== next.projectId), next];
172
+ if (raw.concurrency !== void 0) this.state.concurrency = raw.concurrency;
173
+ this.state.revision++;
174
+ await this.persist();
175
+ this.changed();
176
+ return this.view(ownerActor());
177
+ });
178
+ }
179
+ async authorize(job) {
180
+ this.require();
181
+ if (!this.options.enabled()) throw new CollabError("Host execution is disabled or unavailable", 403);
182
+ const member = this.options.members().find((m) => m.userId === job.userId && m.enabled);
183
+ const p = this.state.policies.find((p2) => p2.projectId === job.projectId);
184
+ if (!member || !roleAtLeast(member.roles[job.projectId], "contributor") || !p?.enabled || !p.userIds.includes(job.userId) || JSON.stringify(p) !== JSON.stringify(job.policy)) throw new CollabError("Host execution permission or settings changed; ask the owner and queue again", 403);
185
+ const task = await this.options.store.task(memberActor(member, "browser"), job.taskId);
186
+ if (task.owner?.userId !== job.userId || taskHash(task) !== job.contentHash) throw new CollabError("Task changed while queued; review it and queue again", 409);
187
+ const mapped = this.options.resolveRepo(task.projectId, task.repository);
188
+ const repos = this.options.projectRepos(task.projectId);
189
+ if (!mapped || mapped !== job.repo || !repos?.includes(mapped) || await realpath(mapped) !== mapped) throw new CollabError("The host repository mapping is unavailable or changed", 409);
190
+ return task;
191
+ }
192
+ enqueue(actor, taskId, raw) {
193
+ return this.serial(async () => {
194
+ this.require();
195
+ keys(raw, ["revision"]);
196
+ if (actor.kind !== "member" || actor.via !== "browser") throw new CollabError("Sign in with your teammate account to queue a host task", 403);
197
+ const member = this.options.members().find((m) => m.userId === actor.userId && m.enabled);
198
+ if (!member) throw new CollabError("Account unavailable", 403);
199
+ const task = await this.options.store.task(memberActor(member, "browser"), taskId);
200
+ if (raw.revision !== task.revision) throw new CollabError("Task changed; reload before queuing", 409);
201
+ const p = this.state.policies.find((p2) => p2.projectId === task.projectId);
202
+ if (!p) throw new CollabError("The owner has not enabled host execution for this project", 403);
203
+ const job = { id: `h-${randomBytes(8).toString("hex")}`, taskId, projectId: task.projectId, userId: member.userId, username: member.username, displayName: member.displayName, state: "queued", createdAt: Date.now(), policy: structuredClone(p), repo: this.options.resolveRepo(task.projectId, task.repository) ?? "", contentHash: taskHash(task) };
204
+ await this.authorize(job);
205
+ if (task.status === "done" || busyAttempt(task) || task.handoff?.state === "offered") throw new CollabError("This task cannot start while done, executing or being handed over", 409);
206
+ const duplicate = this.state.jobs.find((j) => active(j) && j.taskId === taskId);
207
+ if (duplicate) return publicJob(duplicate);
208
+ if (this.state.jobs.filter(active).length >= 50) throw new CollabError("The host queue is full (50 tasks)", 409);
209
+ this.state.jobs = [...this.state.jobs.filter(active), ...this.state.jobs.filter((j) => !active(j)).slice(-199)].sort((a, b) => a.createdAt - b.createdAt);
210
+ this.state.jobs.push(job);
211
+ await this.persist();
212
+ this.changed(job);
213
+ return publicJob(job);
214
+ });
215
+ }
216
+ cancel(actor, jobId) {
217
+ return this.serial(async () => {
218
+ this.require();
219
+ const visible = this.view(actor).jobs.find((j) => j.id === jobId);
220
+ if (!visible) throw new CollabError("Host task not found", 404);
221
+ if (actor.kind !== "owner" && visible.userId !== actor.userId) throw new CollabError("Only the requester or host owner may cancel this task", 403);
222
+ const job = this.state.jobs.find((j) => j.id === jobId);
223
+ if (!active(job)) return publicJob(job);
224
+ if (job.state === "queued") {
225
+ job.state = "cancelled";
226
+ job.endedAt = Date.now();
227
+ } else {
228
+ job.state = "stopping";
229
+ }
230
+ await this.persist();
231
+ if (job.runId && job.state === "stopping") this.options.manager.cancel(job.runId);
232
+ this.changed(job);
233
+ return publicJob(job);
234
+ });
235
+ }
236
+ runner(job) {
237
+ let runner = this.runners.get(job.id);
238
+ if (runner) return runner;
239
+ const actor = memberActor({ userId: job.userId, username: job.username, displayName: job.displayName, enabled: true, roles: { [job.projectId]: "contributor" } }, "runner", `r-${job.id.slice(2)}`);
240
+ const hub = new LocalHub(this.options.store, actor, { runnerId: actor.kind === "member" ? actor.runnerId : void 0, runnerName: "Host computer" });
241
+ runner = new CollabRunner({ manager: this.options.manager, findPattern: this.options.findPattern, hub: () => hub, dir: join(this.dir, job.id), projectFor: () => job.projectId, beforeStart: async () => {
242
+ await this.authorize(job);
243
+ } });
244
+ this.runners.set(job.id, runner);
245
+ return runner;
246
+ }
247
+ tick() {
248
+ return this.serial(async () => {
249
+ this.require();
250
+ for (const job of this.state.jobs.filter((j) => active(j) && j.state !== "queued")) {
251
+ const runner = this.runner(job), run = job.runId ? this.options.manager.get(job.runId) : void 0;
252
+ if (run?.status === "running") {
253
+ if (job.state !== "stopping") {
254
+ try {
255
+ await this.authorize(job);
256
+ } catch {
257
+ job.state = "stopping";
258
+ job.message = "Stopped because execution permission, task or repository changed.";
259
+ await this.persist();
260
+ this.changed(job);
261
+ }
262
+ }
263
+ if (job.state === "stopping") this.options.manager.cancel(run.id);
264
+ continue;
265
+ }
266
+ await runner.reconcile();
267
+ const record = await runner.attemptForTask(job.taskId);
268
+ if (record?.pendingFinish) continue;
269
+ job.state = job.state === "stopping" ? record?.state === "failed" ? "failed" : "cancelled" : run?.status === "done" ? "finished" : run?.status === "interrupted" || !run ? "interrupted" : "failed";
270
+ job.endedAt = Date.now();
271
+ await this.persist();
272
+ this.changed(job);
273
+ await runner.close();
274
+ this.runners.delete(job.id);
275
+ }
276
+ while (this.state.jobs.filter((j) => active(j) && j.state !== "queued").length < this.state.concurrency) {
277
+ const job = this.state.jobs.find((j) => j.state === "queued");
278
+ if (!job) break;
279
+ try {
280
+ const task = await this.authorize(job);
281
+ if (!(await this.options.availableAgents()).includes(job.policy.agent)) throw new CollabError("The configured CLI is unavailable on the host");
282
+ job.state = "starting";
283
+ await this.persist();
284
+ this.changed(job);
285
+ const checkpoint = latestCheckpoint(task);
286
+ const started = await this.runner(job).startAttempt(task.id, { repo: job.repo, pattern: "solo", roles: { agent: [job.policy.agent] }, trust: job.policy.trust, loop: { maxMinutes: job.policy.maxMinutes, maxSteps: job.policy.maxSteps }, ...checkpoint ? { fromCheckpointId: checkpoint.id } : {} });
287
+ Object.assign(job, started);
288
+ job.state = "running";
289
+ await this.persist();
290
+ this.changed(job);
291
+ } catch (error) {
292
+ if (!this.ready) throw error;
293
+ const runner = this.runners.get(job.id), record = await runner?.attemptForTask(job.taskId);
294
+ if (record?.runId || record?.pendingFinish) {
295
+ job.runId = record.runId;
296
+ job.attemptId = record.attemptId;
297
+ job.state = "stopping";
298
+ if (record.runId) this.options.manager.cancel(record.runId);
299
+ } else {
300
+ job.state = "failed";
301
+ job.endedAt = Date.now();
302
+ }
303
+ job.message = error instanceof CollabError && !record ? error.message : "The host could not start this task. Ask the owner to check its execution log.";
304
+ logError(`Host task ${job.id} could not start`, error);
305
+ await this.persist();
306
+ this.changed(job);
307
+ if (runner && !record?.pendingFinish && !record?.runId) {
308
+ await runner.close();
309
+ this.runners.delete(job.id);
310
+ }
311
+ }
312
+ }
313
+ });
314
+ }
315
+ async close() {
316
+ this.closed = true;
317
+ if (this.timer) clearInterval(this.timer);
318
+ await this.chain.catch(() => {
319
+ });
320
+ await Promise.all([...this.runners.values()].map((runner) => runner.close()));
321
+ }
322
+ }
323
+ async function handleHostExecution(req, res, parts, queue, actor, mutation) {
324
+ try {
325
+ const method = req.method ?? "GET";
326
+ if (parts.length === 2 && method === "GET") return send(res, 200, queue.view(actor));
327
+ if (method !== "GET") mutation();
328
+ if (parts.length === 3 && parts[2] === "settings" && method === "PUT") {
329
+ if (actor.kind !== "owner") throw new CollabError("Only the host owner may grant execution", 403);
330
+ return send(res, 200, await queue.configure(await readJson(req, 32 * 1024, { timeoutMs: 1e4 })));
331
+ }
332
+ if (parts.length === 4 && parts[2] === "tasks" && method === "POST") return send(res, 202, await queue.enqueue(actor, parts[3], await readJson(req, 1024, { timeoutMs: 1e4 })));
333
+ if (parts.length === 5 && parts[2] === "jobs" && parts[4] === "cancel" && method === "POST") {
334
+ const raw = await readJson(req, 1024, { timeoutMs: 1e4 });
335
+ keys(raw, []);
336
+ return send(res, 200, await queue.cancel(actor, parts[3]));
337
+ }
338
+ throw new CollabError("Unknown host execution operation", 404);
339
+ } catch (error) {
340
+ if (error instanceof CollabError) return send(res, error.status, { error: error.message });
341
+ throw error;
342
+ }
343
+ }
344
+ export {
345
+ HostExecution,
346
+ handleHostExecution
347
+ };
@@ -0,0 +1,210 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants, createReadStream } from "node:fs";
3
+ import { copyFile, lstat, open, rm } from "node:fs/promises";
4
+ import { hostname } from "node:os";
5
+ import { Readable } from "node:stream";
6
+ import { CollabError, MAX, SHA256_HEX } from "./model.js";
7
+ import { RUNNER_LOCAL, ownerActor } from "./store.js";
8
+ const enc = encodeURIComponent;
9
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
10
+ const BUNDLE_TIMEOUT_MS = 10 * 6e4;
11
+ class LocalHub {
12
+ constructor(store, actor, options = {}) {
13
+ this.store = store;
14
+ this.actorOf = typeof actor === "function" ? actor : actor ? () => actor : () => ownerActor(store.ownerName);
15
+ this.runnerId = options.runnerId ?? RUNNER_LOCAL;
16
+ this.runnerName = options.runnerName ?? (hostname() || "this computer").slice(0, MAX.runnerName);
17
+ }
18
+ runnerId;
19
+ runnerName;
20
+ actorOf;
21
+ get actor() {
22
+ return this.actorOf();
23
+ }
24
+ async me() {
25
+ const board = await this.store.board(this.actor);
26
+ const { runners, ...me } = board.me;
27
+ return { me, runners, linked: false };
28
+ }
29
+ board(projectId) {
30
+ return this.store.board(this.actor, projectId);
31
+ }
32
+ task(id) {
33
+ return this.store.task(this.actor, id);
34
+ }
35
+ inbox(after) {
36
+ return this.store.inbox(this.actor, after);
37
+ }
38
+ claimAttempt(taskId, input) {
39
+ return this.store.claimAttempt(this.actor, taskId, input);
40
+ }
41
+ progress(attemptId, report) {
42
+ return this.store.attemptProgress(this.actor, attemptId, report);
43
+ }
44
+ heartbeat(attemptId) {
45
+ return this.store.heartbeat(this.actor, attemptId);
46
+ }
47
+ finish(attemptId, input) {
48
+ return this.store.finishAttempt(this.actor, attemptId, input);
49
+ }
50
+ addCheckpoint(attemptId, input) {
51
+ return this.store.addCheckpoint(this.actor, attemptId, input);
52
+ }
53
+ uploadBundle(checkpointId, file, info) {
54
+ return this.store.storeBundle(this.actor, checkpointId, createReadStream(file), info);
55
+ }
56
+ async downloadBundle(checkpointId, toFile) {
57
+ const { path, checkpoint } = await this.store.bundlePath(this.actor, checkpointId);
58
+ try {
59
+ await copyFile(path, toFile, constants.COPYFILE_EXCL);
60
+ } catch (error) {
61
+ throw new CollabError(`The checkpoint bundle could not be copied to ${toFile}: ${error instanceof Error ? error.message : String(error)}`, 500);
62
+ }
63
+ return checkpoint;
64
+ }
65
+ markInstructionDelivered(taskId, instructionId, attemptId) {
66
+ return this.store.markInstructionDelivered(this.actor, taskId, instructionId, attemptId);
67
+ }
68
+ offerHandoff(taskId, input, revision) {
69
+ return this.store.offerHandoff(this.actor, taskId, input, revision);
70
+ }
71
+ respondHandoff(taskId, handoffId, action, revision) {
72
+ return this.store.respondHandoff(this.actor, taskId, handoffId, action, revision);
73
+ }
74
+ }
75
+ class RemoteHub {
76
+ constructor(link) {
77
+ this.link = link;
78
+ }
79
+ get runnerId() {
80
+ return this.link.runnerId;
81
+ }
82
+ get runnerName() {
83
+ return this.link.runnerName;
84
+ }
85
+ get url() {
86
+ return this.link.url;
87
+ }
88
+ me() {
89
+ return this.link.json("/api/team/me");
90
+ }
91
+ board(projectId) {
92
+ return this.link.json(`/api/team/board${projectId ? `?project=${enc(projectId)}` : ""}`);
93
+ }
94
+ task(id) {
95
+ return this.link.json(`/api/team/tasks/${enc(id)}`);
96
+ }
97
+ inbox(after) {
98
+ return this.link.json(`/api/team/inbox${after === void 0 ? "" : `?after=${enc(String(after))}`}`);
99
+ }
100
+ claimAttempt(taskId, input) {
101
+ return this.link.json(`/api/team/tasks/${enc(taskId)}/attempts`, { method: "POST", json: input });
102
+ }
103
+ progress(attemptId, report) {
104
+ return this.link.json(`/api/team/attempts/${enc(attemptId)}/progress`, { method: "POST", json: report });
105
+ }
106
+ heartbeat(attemptId) {
107
+ return this.link.json(`/api/team/attempts/${enc(attemptId)}/heartbeat`, { method: "POST", json: {} });
108
+ }
109
+ finish(attemptId, input) {
110
+ return this.link.json(`/api/team/attempts/${enc(attemptId)}/finish`, { method: "POST", json: input });
111
+ }
112
+ addCheckpoint(attemptId, input) {
113
+ return this.link.json(`/api/team/attempts/${enc(attemptId)}/checkpoints`, { method: "POST", json: input });
114
+ }
115
+ /** The bytes travel as one `application/octet-stream` body with an exact `content-length` and the digest the hub verifies while it streams to disk. */
116
+ async uploadBundle(checkpointId, file, info) {
117
+ if (!object(info) || !Number.isSafeInteger(info.size) || info.size < 1 || info.size > MAX.bundle || typeof info.sha256 !== "string" || !SHA256_HEX.test(info.sha256)) throw new CollabError("Upload a bundle of 1 byte to 64 MiB with its sha256 digest", 400);
118
+ const stat = await lstat(file).catch(() => void 0);
119
+ if (!stat?.isFile() || stat.size !== info.size) throw new CollabError(`The bundle file ${file} is missing or its size differs from the manifest; create the checkpoint again`, 400);
120
+ const body = Readable.toWeb(createReadStream(file));
121
+ const result = await this.link.json(`/api/team/checkpoints/${enc(checkpointId)}/bundle`, { method: "PUT", body, duplex: "half", headers: { "content-type": "application/octet-stream", "content-length": String(info.size), "x-codeflow-sha256": info.sha256 }, timeoutMs: BUNDLE_TIMEOUT_MS });
122
+ return "checkpoint" in result && object(result.checkpoint) ? result.checkpoint : result;
123
+ }
124
+ /** The manifest comes first so the bytes can be checked against what the hub promised; a short or tampered download never reaches the importer. */
125
+ async downloadBundle(checkpointId, toFile) {
126
+ const manifest = await this.link.json(`/api/team/checkpoints/${enc(checkpointId)}`);
127
+ if (manifest.bundle !== "available") throw new CollabError("Checkpoint bundle is not available", 404);
128
+ const response = await this.link.request(`/api/team/checkpoints/${enc(checkpointId)}/bundle`, { headers: { accept: "application/octet-stream" }, timeoutMs: BUNDLE_TIMEOUT_MS });
129
+ if (!response.ok) {
130
+ const error = await readError(response);
131
+ throw new CollabError(error.message, error.status);
132
+ }
133
+ const declared = Number(response.headers.get("content-length")), digest = (response.headers.get("x-codeflow-sha256") ?? "").toLowerCase();
134
+ if (declared !== manifest.size || digest !== manifest.sha256) {
135
+ await response.body?.cancel().catch(() => void 0);
136
+ throw new CollabError("The team server's bundle headers do not match the checkpoint manifest; retry, or delete the checkpoint if it keeps failing", 502);
137
+ }
138
+ if (!response.body) throw new CollabError("The team server sent an empty bundle; retry", 502);
139
+ const hash = createHash("sha256");
140
+ let received = 0;
141
+ const handle = await open(toFile, "wx", 384);
142
+ try {
143
+ for await (const chunk of Readable.fromWeb(response.body)) {
144
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
145
+ received += bytes.length;
146
+ if (received > manifest.size) throw new CollabError("The team server sent more bundle bytes than the manifest says; retry", 502);
147
+ hash.update(bytes);
148
+ await handle.write(bytes);
149
+ }
150
+ await handle.sync();
151
+ } catch (error) {
152
+ await handle.close();
153
+ await rm(toFile, { force: true }).catch(() => void 0);
154
+ throw error instanceof CollabError ? error : new CollabError(`The bundle download failed: ${error instanceof Error ? error.message : String(error)}; retry`, 502);
155
+ }
156
+ await handle.close();
157
+ if (received !== manifest.size || hash.digest("hex") !== manifest.sha256) {
158
+ await rm(toFile, { force: true }).catch(() => void 0);
159
+ throw new CollabError("The downloaded bundle does not match the checkpoint's size or sha256 digest; retry", 502);
160
+ }
161
+ return manifest;
162
+ }
163
+ markInstructionDelivered(taskId, instructionId, attemptId) {
164
+ return this.link.json(`/api/team/tasks/${enc(taskId)}/instructions/${enc(instructionId)}`, { method: "POST", json: { action: "delivered", attemptId } });
165
+ }
166
+ offerHandoff(taskId, input, revision) {
167
+ return this.link.json(`/api/team/tasks/${enc(taskId)}/handoff`, { method: "POST", json: { ...input, revision } });
168
+ }
169
+ respondHandoff(taskId, handoffId, action, revision) {
170
+ return this.link.json(`/api/team/tasks/${enc(taskId)}/handoff/${enc(handoffId)}`, { method: "POST", json: { action, revision } });
171
+ }
172
+ }
173
+ async function readBounded(response, limit, options = {}) {
174
+ if (!response.body) return "";
175
+ const declared = Number(response.headers.get("content-length") ?? 0);
176
+ if (declared > limit && !options.truncate) {
177
+ await response.body.cancel().catch(() => void 0);
178
+ throw new CollabError(`The team server's answer exceeds ${Math.round(limit / 1024)} KiB`, 502);
179
+ }
180
+ const chunks = [];
181
+ let size = 0;
182
+ for await (const chunk of Readable.fromWeb(response.body)) {
183
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk);
184
+ size += bytes.length;
185
+ if (size > limit) {
186
+ if (!options.truncate) throw new CollabError(`The team server's answer exceeds ${Math.round(limit / 1024)} KiB`, 502);
187
+ chunks.push(bytes.subarray(0, bytes.length - (size - limit)));
188
+ break;
189
+ }
190
+ chunks.push(bytes);
191
+ }
192
+ return Buffer.concat(chunks).toString("utf8");
193
+ }
194
+ async function readError(response, connecting = false, limit = 64 * 1024) {
195
+ let message = "";
196
+ try {
197
+ const text = await readBounded(response, limit, { truncate: true });
198
+ const parsed = JSON.parse(text);
199
+ if (object(parsed) && typeof parsed.error === "string") message = parsed.error.slice(0, 2e3);
200
+ } catch {
201
+ }
202
+ if (response.status === 401 && !connecting) return new CollabError(`${message || "The team server no longer accepts this computer's credential"}; reconnect in Settings \u2192 Team`, 401);
203
+ return new CollabError(message || `The team server answered HTTP ${response.status}`, response.status >= 400 && response.status <= 599 ? response.status : 502);
204
+ }
205
+ export {
206
+ LocalHub,
207
+ RemoteHub,
208
+ readBounded,
209
+ readError
210
+ };