@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,1452 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { CODEFLOW_HOME } from "../config.js";
6
+ import { logError, logWarn } from "../log.js";
7
+ import { actorRole, assertCanChangeOwner, assignOwner, ATTEMPT_ID, ATTEMPT_STATES, busyAttempt, CHECKPOINT_ID, clone, CollabError, COMMIT_ID, isTaskOwner, latestCheckpoint, MAX, mentionsIn, newCheckpointId, newId, newRunnerId, newRunnerToken, newTaskId, ownerChangedMessage, PATH_ID, PREFIXED_ID, pushBounded, recordAudit, REPO_KEY, roleAtLeast, RUNNER_ID, RUNNER_TOKEN, SHA256_HEX, summarize, TASK_ID, TASK_STATUSES, transition, truncateText, validateCheckpointInput, validateCommit, validateProgress, validateProgressReport, validateRepoRefs, validateTaskInput, validateTaskPatch, validateText, validateUserRef, whoOf } from "./model.js";
8
+ const RUNNER_LOCAL = "local";
9
+ const READ_LIMIT = 16 * 1024 * 1024, RING = 1e3, TOUCH_MS = 1e4, FLUSH_MS = 3e4, RETENTION_MS = 30 * 24 * 60 * 6e4, STALE_ATTEMPT_MS = 24 * 60 * 6e4;
10
+ const TERMINAL = ["finished", "failed", "superseded"], FINISH_STATES = ["finished", "failed", "stopped", "paused", "unknown"];
11
+ const INBOX_KINDS = ["assigned", "mention", "review-requested", "review-recorded", "handoff-offered", "handoff-accepted", "handoff-declined", "handoff-cancelled", "instruction-proposed", "blocked", "attempt-finished", "attempt-failed", "comment"];
12
+ const sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
13
+ const short = (commit) => commit.slice(0, 12);
14
+ const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
15
+ const integer = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
16
+ const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
17
+ const ownerActor = (displayName = "Owner") => ({ kind: "owner", userId: "owner", displayName });
18
+ const memberActor = (user, via, runnerId) => {
19
+ const actor = { kind: "member", userId: user.userId, username: user.username, displayName: user.displayName, roles: { ...user.roles }, via };
20
+ if (runnerId !== void 0) actor.runnerId = runnerId;
21
+ return actor;
22
+ };
23
+ const runnerPublic = (runner, now) => {
24
+ const pub = { id: runner.id, userId: runner.userId, name: runner.name, createdAt: runner.createdAt, lastSeenAt: runner.lastSeenAt, online: runner.revokedAt === void 0 && now - runner.lastSeenAt < MAX.heartbeatLiveMs };
25
+ if (runner.revokedAt !== void 0) pub.revokedAt = runner.revokedAt;
26
+ return pub;
27
+ };
28
+ class InvalidDocument extends Error {
29
+ }
30
+ function invalid(what) {
31
+ throw new InvalidDocument(what);
32
+ }
33
+ function fields(v, allowed, what) {
34
+ if (!object(v)) invalid(`${what} is not an object`);
35
+ const extra = Object.keys(v).find((key) => !allowed.includes(key));
36
+ if (extra !== void 0) invalid(`${what} has an unknown field ${JSON.stringify(extra.slice(0, 40))}`);
37
+ return v;
38
+ }
39
+ function time(v, what) {
40
+ if (!integer(v)) invalid(`${what} is not a time`);
41
+ return v;
42
+ }
43
+ function matching(v, pattern, what) {
44
+ if (typeof v !== "string" || !pattern.test(v) || UNSAFE_KEYS.includes(v)) invalid(`${what} has an unexpected format`);
45
+ return v;
46
+ }
47
+ function text(v, max, what, required = false) {
48
+ if (v === void 0 && !required) return "";
49
+ try {
50
+ return validateText(v, max, what, { required });
51
+ } catch (error) {
52
+ return invalid(error instanceof Error ? error.message : String(error));
53
+ }
54
+ }
55
+ function oneOf(v, values, what) {
56
+ if (typeof v !== "string" || !values.includes(v)) invalid(`${what} is not one of ${values.join(", ")}`);
57
+ return v;
58
+ }
59
+ function list(v, max, what) {
60
+ if (!Array.isArray(v) || v.length > max) invalid(`${what} is not a list of at most ${max} entries`);
61
+ return v;
62
+ }
63
+ function who(v, what) {
64
+ const w = fields(v, ["userId", "displayName", "via"], what);
65
+ matching(w.userId, PATH_ID, `${what} user id`);
66
+ text(w.displayName, MAX.displayName, `${what} display name`, true);
67
+ if (w.via !== void 0) oneOf(w.via, ["browser", "runner", "local"], `${what} via`);
68
+ return w;
69
+ }
70
+ function model(what, run) {
71
+ try {
72
+ return run();
73
+ } catch (error) {
74
+ return invalid(`${what}: ${error instanceof Error ? error.message : String(error)}`);
75
+ }
76
+ }
77
+ const runnerIdOk = (v) => v === RUNNER_LOCAL || typeof v === "string" && RUNNER_ID.test(v);
78
+ const TASK_KEYS = ["id", "projectId", "repository", "title", "goal", "acceptance", "status", "blocker", "owner", "collaborators", "reviewer", "dependsOn", "baseRef", "labels", "revision", "createdAt", "updatedAt", "createdBy", "attempts", "checkpoints", "decisions", "discussion", "instructions", "reviews", "handoff", "handoffs", "audit"];
79
+ const ATTEMPT_KEYS = ["id", "taskId", "runnerId", "runnerName", "user", "state", "runId", "baseCommit", "fromCheckpointId", "branch", "head", "createdAt", "startedAt", "endedAt", "lastSeq", "lastHeartbeatAt", "progress", "checkpointId", "reason"];
80
+ const CHECKPOINT_KEYS = ["id", "taskId", "attemptId", "commit", "baseCommit", "branch", "size", "sha256", "ref", "createdBy", "at", "note", "requirements", "decisions", "testResults", "remaining", "changedFiles", "bundle"];
81
+ function validateAttemptRecord(v, taskId) {
82
+ const a = fields(v, ATTEMPT_KEYS, "attempt");
83
+ matching(a.id, ATTEMPT_ID, "attempt id");
84
+ if (a.taskId !== taskId) invalid("attempt task id does not match its task");
85
+ if (!runnerIdOk(a.runnerId)) invalid("attempt runner id has an unexpected format");
86
+ text(a.runnerName, MAX.runnerName, "runner name", true);
87
+ who(a.user, "attempt user");
88
+ oneOf(a.state, ATTEMPT_STATES, "attempt state");
89
+ if (a.runId !== void 0) matching(a.runId, PATH_ID, "attempt run id");
90
+ matching(a.baseCommit, COMMIT_ID, "attempt base commit");
91
+ if (a.fromCheckpointId !== void 0) matching(a.fromCheckpointId, CHECKPOINT_ID, "attempt checkpoint id");
92
+ if (a.branch !== void 0) text(a.branch, MAX.branch, "attempt branch", true);
93
+ if (a.head !== void 0) matching(a.head, COMMIT_ID, "attempt head");
94
+ time(a.createdAt, "attempt createdAt");
95
+ if (a.startedAt !== void 0) time(a.startedAt, "attempt startedAt");
96
+ if (a.endedAt !== void 0) time(a.endedAt, "attempt endedAt");
97
+ if (!integer(a.lastSeq)) invalid("attempt lastSeq is not a count");
98
+ time(a.lastHeartbeatAt, "attempt heartbeat");
99
+ model("attempt progress", () => validateProgress(a.progress));
100
+ if (!object(a.progress) || !integer(a.progress.updatedAt)) invalid("attempt progress has no update time");
101
+ if (a.checkpointId !== void 0) matching(a.checkpointId, CHECKPOINT_ID, "attempt checkpoint");
102
+ if (a.reason !== void 0) text(a.reason, MAX.reason, "attempt reason");
103
+ return a;
104
+ }
105
+ function validateCheckpointRecord(v, taskId) {
106
+ const c = fields(v, CHECKPOINT_KEYS, "checkpoint"), id = matching(c.id, CHECKPOINT_ID, "checkpoint id");
107
+ if (c.taskId !== taskId) invalid("checkpoint task id does not match its task");
108
+ matching(c.attemptId, ATTEMPT_ID, "checkpoint attempt id");
109
+ model("checkpoint manifest", () => validateCheckpointInput({ commit: c.commit, baseCommit: c.baseCommit, branch: c.branch, size: c.size, sha256: c.sha256, note: c.note, requirements: c.requirements, decisions: c.decisions, testResults: c.testResults, remaining: c.remaining, changedFiles: c.changedFiles }));
110
+ if (c.ref !== `refs/codeflow/team/${id}`) invalid("checkpoint ref does not match its id");
111
+ who(c.createdBy, "checkpoint creator");
112
+ time(c.at, "checkpoint time");
113
+ oneOf(c.bundle, ["pending", "available", "deleted"], "checkpoint bundle state");
114
+ return c;
115
+ }
116
+ function validateEntryRecord(v, kind) {
117
+ switch (kind) {
118
+ case "comment": {
119
+ const e = fields(v, ["id", "by", "text", "mentions", "at", "kind"], "comment");
120
+ matching(e.id, PREFIXED_ID, "comment id");
121
+ who(e.by, "comment author");
122
+ text(e.text, MAX.text, "comment", true);
123
+ for (const m of list(e.mentions, 200, "comment mentions")) matching(m, PATH_ID, "mention");
124
+ time(e.at, "comment time");
125
+ oneOf(e.kind, ["comment", "review-request", "system"], "comment kind");
126
+ return;
127
+ }
128
+ case "decision": {
129
+ const e = fields(v, ["id", "by", "text", "at"], "decision");
130
+ matching(e.id, PREFIXED_ID, "decision id");
131
+ who(e.by, "decision author");
132
+ text(e.text, MAX.decision, "decision", true);
133
+ time(e.at, "decision time");
134
+ return;
135
+ }
136
+ case "instruction": {
137
+ const e = fields(v, ["id", "by", "text", "at", "state", "decidedBy", "decidedAt", "attemptId"], "instruction");
138
+ matching(e.id, PREFIXED_ID, "instruction id");
139
+ who(e.by, "instruction author");
140
+ text(e.text, MAX.text, "instruction", true);
141
+ time(e.at, "instruction time");
142
+ oneOf(e.state, ["proposed", "approved", "declined", "delivered"], "instruction state");
143
+ if (e.decidedBy !== void 0) who(e.decidedBy, "instruction decider");
144
+ if (e.decidedAt !== void 0) time(e.decidedAt, "instruction decision time");
145
+ if (e.attemptId !== void 0) matching(e.attemptId, ATTEMPT_ID, "instruction attempt");
146
+ return;
147
+ }
148
+ case "review": {
149
+ const e = fields(v, ["id", "checkpointId", "commit", "by", "verdict", "text", "at", "stale"], "review");
150
+ matching(e.id, PREFIXED_ID, "review id");
151
+ matching(e.checkpointId, CHECKPOINT_ID, "review checkpoint");
152
+ matching(e.commit, COMMIT_ID, "review commit");
153
+ who(e.by, "reviewer");
154
+ oneOf(e.verdict, ["approve", "request-changes"], "review verdict");
155
+ text(e.text, MAX.text, "review");
156
+ time(e.at, "review time");
157
+ if (typeof e.stale !== "boolean") invalid("review stale flag");
158
+ return;
159
+ }
160
+ case "handoff": {
161
+ const e = fields(v, ["id", "from", "to", "checkpointId", "note", "state", "offeredAt", "respondedAt", "reason"], "hand-over");
162
+ matching(e.id, PREFIXED_ID, "hand-over id");
163
+ who(e.from, "hand-over sender");
164
+ who(e.to, "hand-over recipient");
165
+ matching(e.checkpointId, CHECKPOINT_ID, "hand-over checkpoint");
166
+ text(e.note, MAX.note, "hand-over note");
167
+ oneOf(e.state, ["offered", "accepted", "declined", "cancelled"], "hand-over state");
168
+ time(e.offeredAt, "hand-over time");
169
+ if (e.respondedAt !== void 0) time(e.respondedAt, "hand-over response time");
170
+ if (e.reason !== void 0) text(e.reason, MAX.reason, "hand-over reason");
171
+ return;
172
+ }
173
+ case "audit": {
174
+ const e = fields(v, ["at", "by", "action", "detail"], "audit entry");
175
+ time(e.at, "audit time");
176
+ who(e.by, "audit actor");
177
+ text(e.action, MAX.auditAction, "audit action", true);
178
+ if (e.detail !== void 0) text(e.detail, MAX.auditDetail, "audit detail");
179
+ return;
180
+ }
181
+ }
182
+ }
183
+ function validateTaskRecord(v) {
184
+ const t = fields(v, TASK_KEYS, "task"), id = matching(t.id, TASK_ID, "task id");
185
+ matching(t.projectId, PATH_ID, "task project id");
186
+ matching(t.repository, REPO_KEY, "task repository key");
187
+ text(t.title, MAX.title, "title", true);
188
+ text(t.goal, MAX.goal, "goal");
189
+ text(t.acceptance, MAX.acceptance, "acceptance criteria");
190
+ const status = oneOf(t.status, TASK_STATUSES, "task status");
191
+ if (t.blocker !== void 0) text(t.blocker, MAX.blocker, "blocker", true);
192
+ if (status === "blocked" && typeof t.blocker !== "string") invalid("blocked task has no blocker");
193
+ if (t.owner !== void 0) who(t.owner, "task owner");
194
+ for (const c of list(t.collaborators, MAX.collaborators, "collaborators")) who(c, "collaborator");
195
+ if (t.reviewer !== void 0) who(t.reviewer, "task reviewer");
196
+ for (const d of list(t.dependsOn, MAX.dependsOn, "dependencies")) matching(d, TASK_ID, "dependency");
197
+ if (t.baseRef !== void 0) text(t.baseRef, MAX.baseRef, "base branch", true);
198
+ for (const l of list(t.labels, MAX.labels, "labels")) text(l, MAX.label, "label", true);
199
+ if (!integer(t.revision) || t.revision < 1) invalid("task revision is not a positive count");
200
+ time(t.createdAt, "task createdAt");
201
+ time(t.updatedAt, "task updatedAt");
202
+ who(t.createdBy, "task creator");
203
+ const attempts = /* @__PURE__ */ new Set();
204
+ for (const a of list(t.attempts, MAX.attempts, "attempts")) {
205
+ const attempt = validateAttemptRecord(a, id);
206
+ if (attempts.has(attempt.id)) invalid(`duplicate attempt ${attempt.id}`);
207
+ attempts.add(attempt.id);
208
+ }
209
+ const checkpoints = /* @__PURE__ */ new Set();
210
+ for (const c of list(t.checkpoints, MAX.checkpoints, "checkpoints")) {
211
+ const checkpoint = validateCheckpointRecord(c, id);
212
+ if (checkpoints.has(checkpoint.id)) invalid(`duplicate checkpoint ${checkpoint.id}`);
213
+ checkpoints.add(checkpoint.id);
214
+ }
215
+ for (const e of list(t.decisions, MAX.decisions, "decisions")) validateEntryRecord(e, "decision");
216
+ for (const e of list(t.discussion, MAX.discussion, "discussion")) validateEntryRecord(e, "comment");
217
+ for (const e of list(t.instructions, MAX.instructions, "instructions")) validateEntryRecord(e, "instruction");
218
+ for (const e of list(t.reviews, MAX.reviews, "reviews")) validateEntryRecord(e, "review");
219
+ if (t.handoff !== void 0) validateEntryRecord(t.handoff, "handoff");
220
+ for (const e of list(t.handoffs, MAX.handoffs, "hand-over history")) validateEntryRecord(e, "handoff");
221
+ for (const e of list(t.audit, MAX.audit, "audit")) validateEntryRecord(e, "audit");
222
+ return t;
223
+ }
224
+ function validateBoardDocument(raw, trusted) {
225
+ const d = fields(raw, ["version", "revision", "ownerName", "projects", "tasks"], "board");
226
+ if (d.version !== 1) invalid("board version is not 1");
227
+ if (!integer(d.revision)) invalid("board revision is not a count");
228
+ if (d.ownerName !== void 0) text(d.ownerName, MAX.displayName, "owner name", true);
229
+ if (!object(d.projects)) invalid("board projects is not a map");
230
+ for (const [projectId, entry] of Object.entries(d.projects)) {
231
+ matching(projectId, PATH_ID, "project id");
232
+ const p = fields(entry, ["repositories"], `project ${projectId}`);
233
+ model(`project ${projectId} repositories`, () => validateRepoRefs(p.repositories));
234
+ }
235
+ const ids = /* @__PURE__ */ new Set(), attempts = /* @__PURE__ */ new Set(), checkpoints = /* @__PURE__ */ new Set();
236
+ for (const entry of list(d.tasks, MAX.tasks, "tasks")) {
237
+ const task = trusted?.has(entry) ? entry : validateTaskRecord(entry);
238
+ trusted?.add(task);
239
+ if (ids.has(task.id)) invalid(`duplicate task ${task.id}`);
240
+ ids.add(task.id);
241
+ for (const a of task.attempts) {
242
+ if (attempts.has(a.id)) invalid(`attempt ${a.id} appears in two tasks`);
243
+ attempts.add(a.id);
244
+ }
245
+ for (const c of task.checkpoints) {
246
+ if (checkpoints.has(c.id)) invalid(`checkpoint ${c.id} appears in two tasks`);
247
+ checkpoints.add(c.id);
248
+ }
249
+ }
250
+ return d;
251
+ }
252
+ function validateRunnersDocument(raw) {
253
+ const d = fields(raw, ["version", "runners"], "runners");
254
+ if (d.version !== 1) invalid("runners version is not 1");
255
+ const ids = /* @__PURE__ */ new Set(), hashes = /* @__PURE__ */ new Set();
256
+ for (const entry of list(d.runners, MAX.runners, "runners")) {
257
+ const r = fields(entry, ["id", "userId", "name", "tokenHash", "createdAt", "lastSeenAt", "revokedAt", "lastAddress"], "runner"), id = matching(r.id, RUNNER_ID, "runner id"), hash = matching(r.tokenHash, SHA256_HEX, "runner token hash");
258
+ matching(r.userId, PATH_ID, "runner account");
259
+ text(r.name, MAX.runnerName, "runner name", true);
260
+ time(r.createdAt, "runner createdAt");
261
+ time(r.lastSeenAt, "runner lastSeenAt");
262
+ if (r.revokedAt !== void 0) time(r.revokedAt, "runner revokedAt");
263
+ if (r.lastAddress !== void 0) text(r.lastAddress, 128, "runner address");
264
+ if (ids.has(id) || hashes.has(hash)) invalid(`duplicate runner ${id}`);
265
+ ids.add(id);
266
+ hashes.add(hash);
267
+ }
268
+ return d;
269
+ }
270
+ function validateInboxDocument(raw) {
271
+ const d = fields(raw, ["version", "items"], "inbox");
272
+ if (d.version !== 1) invalid("inbox version is not 1");
273
+ const ids = /* @__PURE__ */ new Set();
274
+ for (const entry of list(d.items, MAX.inboxTotal, "inbox items")) {
275
+ const i = fields(entry, ["id", "userId", "kind", "taskId", "taskTitle", "text", "by", "at", "readAt"], "inbox item"), id = matching(i.id, PREFIXED_ID, "inbox item id");
276
+ matching(i.userId, PATH_ID, "inbox account");
277
+ oneOf(i.kind, INBOX_KINDS, "inbox kind");
278
+ matching(i.taskId, TASK_ID, "inbox task");
279
+ text(i.taskTitle, MAX.title, "inbox task title", true);
280
+ text(i.text, MAX.inboxText, "inbox text");
281
+ who(i.by, "inbox actor");
282
+ time(i.at, "inbox time");
283
+ if (i.readAt !== void 0) time(i.readAt, "inbox read time");
284
+ if (ids.has(id)) invalid(`duplicate inbox item ${id}`);
285
+ ids.add(id);
286
+ }
287
+ return d;
288
+ }
289
+ const emptyBoard = () => ({ version: 1, revision: 0, projects: {}, tasks: [] }), emptyRunners = () => ({ version: 1, runners: [] }), emptyInbox = () => ({ version: 1, items: [] });
290
+ async function readDocument(file) {
291
+ let body;
292
+ try {
293
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
294
+ try {
295
+ const info = await handle.stat();
296
+ if (!info.isFile()) throw new Error(`${file} is not a regular file`);
297
+ if (info.size > READ_LIMIT) throw new Error(`${file} exceeds the 16 MiB limit`);
298
+ body = await handle.readFile("utf8");
299
+ } finally {
300
+ await handle.close();
301
+ }
302
+ } catch (error) {
303
+ if (error.code === "ENOENT") return void 0;
304
+ throw error;
305
+ }
306
+ try {
307
+ return JSON.parse(body);
308
+ } catch (error) {
309
+ throw new InvalidDocument(`not valid JSON (${error.message})`);
310
+ }
311
+ }
312
+ class CollabStore {
313
+ constructor(options) {
314
+ this.options = options;
315
+ this.dir = options.dir ?? join(CODEFLOW_HOME, "team");
316
+ this.bundleDir = join(this.dir, "checkpoints");
317
+ this.random = options.random ?? randomBytes;
318
+ this.now = options.now ?? Date.now;
319
+ this.epoch = this.random(8).toString("hex");
320
+ }
321
+ dir;
322
+ bundleDir;
323
+ state = { board: emptyBoard(), runners: emptyRunners(), inbox: emptyInbox() };
324
+ loaded = false;
325
+ loading;
326
+ readable = true;
327
+ queue = Promise.resolve();
328
+ listeners = /* @__PURE__ */ new Set();
329
+ ring = [];
330
+ seq = 0;
331
+ /** Random per process: a client whose cursor names another epoch gets a resync instead of a replay from the wrong ring. */
332
+ epoch;
333
+ /** Write-behind state: runner lastSeen touches and board telemetry wait for the next durable change or the flush timer. */
334
+ runnersDirty = false;
335
+ boardPending = false;
336
+ flushTimer;
337
+ /** Task objects validateBoardDocument has passed, so a write re-validates only the tasks the mutation cloned. */
338
+ validated = /* @__PURE__ */ new WeakSet();
339
+ /** Documents that did not load as written (set aside or unreadable); a bundle sweep never trusts a board that was. */
340
+ degraded = /* @__PURE__ */ new Set();
341
+ random;
342
+ now;
343
+ get available() {
344
+ return this.loaded && this.readable;
345
+ }
346
+ /** The hub owner's display name as teammates see it: the saved profile, else what the server passes, else "Owner". */
347
+ get ownerName() {
348
+ return this.state.board.ownerName ?? this.options.ownerName?.() ?? "Owner";
349
+ }
350
+ load() {
351
+ return this.loading ??= (async () => {
352
+ try {
353
+ await mkdir(this.bundleDir, { recursive: true, mode: 448 });
354
+ } catch (error) {
355
+ this.readable = false;
356
+ logError(`Couldn't create ${this.dir}; the team board is unavailable until it can be created`, error);
357
+ }
358
+ const board = await this.loadDocument("board.json", (raw) => validateBoardDocument(raw, this.validated), emptyBoard), runners = await this.loadDocument("runners.json", validateRunnersDocument, emptyRunners), inbox = await this.loadDocument("inbox.json", validateInboxDocument, emptyInbox);
359
+ this.state = { board, runners, inbox };
360
+ this.loaded = true;
361
+ if (this.readable) await this.sweepBundles(board, !this.degraded.has("board.json"));
362
+ })();
363
+ }
364
+ /** Removes what an upload interrupted by a crash, or a crash between a board write and its cleanup, left in `checkpoints/`: temp files always, and bundle files no available checkpoint names only when board.json itself loaded, since a board set aside as corrupt may be repaired and still name them. Runs once, at load, when no upload is in flight. */
365
+ async sweepBundles(board, unreferenced) {
366
+ const wanted = /* @__PURE__ */ new Set();
367
+ for (const task of board.tasks) for (const checkpoint of task.checkpoints) if (checkpoint.bundle === "available") wanted.add(`${checkpoint.id}.bundle`);
368
+ let names;
369
+ try {
370
+ names = await readdir(this.bundleDir);
371
+ } catch (error) {
372
+ logError(`The checkpoint folder ${this.bundleDir} could not be listed for cleanup`, error);
373
+ return;
374
+ }
375
+ const stale = names.filter((name) => /^[a-f0-9]{32}\.bundle\.[a-f0-9]{12}\.tmp$/.test(name) || unreferenced && /^[a-f0-9]{32}\.bundle$/.test(name) && !wanted.has(name));
376
+ for (const name of stale) await rm(join(this.bundleDir, name), { force: true }).catch((error) => logError(`Stale checkpoint file ${name} could not be removed from ${this.bundleDir}`, error));
377
+ if (stale.length) logWarn(`Removed ${stale.length} stale checkpoint file(s) left in ${this.bundleDir} by an interrupted upload or cleanup`);
378
+ }
379
+ /** Persists pending telemetry and runner lastSeen touches and stops the write-behind timer; nothing else is held open. */
380
+ async close() {
381
+ if (this.flushTimer) {
382
+ clearTimeout(this.flushTimer);
383
+ this.flushTimer = void 0;
384
+ }
385
+ if ((this.runnersDirty || this.boardPending) && this.available) await this.change(() => void 0).catch(() => void 0);
386
+ }
387
+ /** Arms the 30 s write-behind once; a durable change in the meantime carries the pending documents along and the timer finds nothing to do. */
388
+ armFlush() {
389
+ this.flushTimer ??= setTimeout(() => {
390
+ this.flushTimer = void 0;
391
+ if ((this.runnersDirty || this.boardPending) && this.available) this.change(() => void 0).catch(() => void 0);
392
+ }, FLUSH_MS);
393
+ this.flushTimer.unref?.();
394
+ }
395
+ onChange(listener) {
396
+ this.listeners.add(listener);
397
+ return () => {
398
+ this.listeners.delete(listener);
399
+ };
400
+ }
401
+ /** Replays the ring after `<epoch>:<seq>`; a cursor from another process or older than the ring asks the client to reload instead of missing changes silently. */
402
+ eventsSince(cursor) {
403
+ const page = { epoch: this.epoch, seq: this.seq, events: [], resync: false };
404
+ if (cursor === void 0 || cursor === "") return page;
405
+ const match = /^([a-f0-9]{16}):(\d{1,15})$/.exec(cursor);
406
+ if (!match || match[1] !== this.epoch) return { ...page, resync: true };
407
+ const after = Number(match[2]), first = this.ring[0];
408
+ if (after > this.seq || after < this.seq && (!first || first.seq > after + 1)) return { ...page, resync: true };
409
+ page.events = this.ring.filter((event) => event.seq > after);
410
+ return page;
411
+ }
412
+ // ---- Reads
413
+ async board(actor, projectId) {
414
+ await this.load();
415
+ this.require();
416
+ const now = this.now(), { board, inbox, runners } = this.state;
417
+ let projects = this.options.projects().filter((project) => actorRole(actor, project.id) !== void 0);
418
+ if (projectId !== void 0) {
419
+ const one = projects.find((project) => project.id === projectId);
420
+ if (!one) throw new CollabError("Project not found", 404);
421
+ projects = [one];
422
+ }
423
+ const visible = new Set(projects.map((project) => project.id)), mentions = /* @__PURE__ */ new Map();
424
+ let inboxUnread = 0;
425
+ for (const item of this.visibleInbox(board, inbox, actor)) if (item.readAt === void 0) {
426
+ inboxUnread++;
427
+ if (item.kind === "mention") mentions.set(item.taskId, (mentions.get(item.taskId) ?? 0) + 1);
428
+ }
429
+ const online = this.onlineUsers(now), roles = actor.kind === "owner" ? Object.fromEntries(this.options.projects().map((project) => [project.id, "maintainer"])) : { ...actor.roles };
430
+ const me = { ...whoOf(actor), roles, runners: runners.runners.filter((runner) => runner.userId === actor.userId).map((runner) => runnerPublic(runner, now)) };
431
+ return { revision: board.revision, projects: projects.map((project) => this.projectView(board, project, actor, online)), tasks: board.tasks.filter((task) => visible.has(task.projectId)).map((task) => summarize(task, actor.userId, now, mentions.get(task.id) ?? 0)), me, inboxUnread };
432
+ }
433
+ async task(actor, id) {
434
+ await this.load();
435
+ this.require();
436
+ return clone(this.findTask(this.state.board, actor, id));
437
+ }
438
+ async inbox(actor, after) {
439
+ await this.load();
440
+ this.require();
441
+ const since = after === void 0 || after === null || after === "" ? void 0 : Number(after);
442
+ if (since !== void 0 && !Number.isSafeInteger(since)) throw new CollabError("Send after as a Unix time in milliseconds");
443
+ const mine = this.visibleInbox(this.state.board, this.state.inbox, actor);
444
+ return { items: clone(mine.filter((item) => since === void 0 || item.at > since).reverse()), unread: mine.filter((item) => item.readAt === void 0).length };
445
+ }
446
+ /** Runners are account-wide: only the hub owner may see or revoke another account's runners. */
447
+ async runners(actor) {
448
+ await this.load();
449
+ this.require();
450
+ const now = this.now(), all = this.seesAllRunners(actor);
451
+ return this.state.runners.runners.filter((runner) => all || runner.userId === actor.userId).map((runner) => runnerPublic(runner, now));
452
+ }
453
+ /** Constant-time against every stored hash; a revoked runner or a disabled account is refused. lastSeenAt moves at most every 10 s and reaches disk with the next mutation or the 30 s write-behind, so a heartbeat storm never turns into an fsync storm. */
454
+ authenticateRunner(token) {
455
+ if (!this.available || typeof token !== "string" || !RUNNER_TOKEN.test(token)) return void 0;
456
+ const hash = Buffer.from(sha256Hex(token), "hex");
457
+ let match;
458
+ for (const runner of this.state.runners.runners) {
459
+ const stored = Buffer.from(runner.tokenHash, "hex");
460
+ if (stored.length === hash.length && timingSafeEqual(stored, hash) && !match) match = runner;
461
+ }
462
+ if (!match || match.revokedAt !== void 0) return void 0;
463
+ const userId = match.userId;
464
+ const member = this.options.members().find((entry) => entry.userId === userId);
465
+ if (!member?.enabled) return void 0;
466
+ const now = this.now();
467
+ if (now - match.lastSeenAt >= TOUCH_MS) {
468
+ match.lastSeenAt = now;
469
+ this.runnersDirty = true;
470
+ this.armFlush();
471
+ }
472
+ return { runnerId: match.id, userId };
473
+ }
474
+ // ---- Shared helpers
475
+ require() {
476
+ if (!this.loaded) throw new CollabError("Team storage has not finished loading; retry in a moment", 503);
477
+ if (!this.readable) throw new CollabError(`Team storage is unavailable; repair the files in ${this.dir} (see server.log), then restart code-flow`, 503);
478
+ }
479
+ bundleFile(checkpointId) {
480
+ return join(this.bundleDir, `${checkpointId}.bundle`);
481
+ }
482
+ onlineUsers(now) {
483
+ const online = /* @__PURE__ */ new Set();
484
+ for (const runner of this.state.runners.runners) if (runner.revokedAt === void 0 && now - runner.lastSeenAt < MAX.heartbeatLiveMs) online.add(runner.userId);
485
+ return online;
486
+ }
487
+ seesAllRunners(actor) {
488
+ return actor.kind === "owner";
489
+ }
490
+ visibleInbox(board, inbox, actor) {
491
+ const projects = new Set(this.options.projects().filter((project) => actorRole(actor, project.id) !== void 0).map((project) => project.id));
492
+ const tasks = new Set(board.tasks.filter((task) => projects.has(task.projectId)).map((task) => task.id));
493
+ return inbox.items.filter((item) => item.userId === actor.userId && tasks.has(item.taskId));
494
+ }
495
+ projectView(board, project, actor, online) {
496
+ const members = [{ userId: "owner", username: "owner", displayName: this.ownerName, role: "maintainer", online: true }];
497
+ for (const member of this.membersOf(project.id)) members.push({ userId: member.userId, username: member.username, displayName: member.displayName, role: member.roles[project.id], online: online.has(member.userId) });
498
+ return { id: project.id, name: project.name, role: actorRole(actor, project.id), repositories: clone(Object.hasOwn(board.projects, project.id) ? board.projects[project.id].repositories : []), members };
499
+ }
500
+ membersOf(projectId) {
501
+ return this.options.members().filter((member) => member.enabled && Object.hasOwn(member.roles, projectId) && member.roles[projectId] !== void 0);
502
+ }
503
+ /** `@owner` reaches the person running the hub first, then members by username (model.mentionsIn keeps the first listed on a clash). */
504
+ mentionTargets(projectId) {
505
+ return [{ userId: "owner", username: "owner" }, ...this.membersOf(projectId).map((member) => ({ userId: member.userId, username: member.username }))];
506
+ }
507
+ /** Unknown ids and ids in projects where the actor holds no role are the same 404, so a task id never confirms existence. */
508
+ findTask(board, actor, id) {
509
+ const task = typeof id === "string" && TASK_ID.test(id) ? board.tasks.find((entry) => entry.id === id) : void 0;
510
+ if (!task || actorRole(actor, task.projectId) === void 0) throw new CollabError("Task not found", 404);
511
+ return task;
512
+ }
513
+ findAttempt(board, actor, id) {
514
+ const found = this.attemptById(board, id);
515
+ if (!found || actorRole(actor, found.task.projectId) === void 0) throw new CollabError("Attempt not found", 404);
516
+ return found;
517
+ }
518
+ attemptById(board, id) {
519
+ if (typeof id !== "string" || !ATTEMPT_ID.test(id)) return void 0;
520
+ for (const task of board.tasks) {
521
+ const attempt = task.attempts.find((entry) => entry.id === id);
522
+ if (attempt) return { task, attempt };
523
+ }
524
+ return void 0;
525
+ }
526
+ findCheckpoint(board, actor, id) {
527
+ if (typeof id === "string" && CHECKPOINT_ID.test(id)) for (const task of board.tasks) {
528
+ const checkpoint = task.checkpoints.find((entry) => entry.id === id);
529
+ if (checkpoint && actorRole(actor, task.projectId) !== void 0) {
530
+ const attempt = task.attempts.find((entry) => entry.id === checkpoint.attemptId);
531
+ if (attempt) return { task, checkpoint, attempt };
532
+ }
533
+ }
534
+ throw new CollabError("Checkpoint not found", 404);
535
+ }
536
+ project(projectId, actor) {
537
+ const project = typeof projectId === "string" ? this.options.projects().find((entry) => entry.id === projectId) : void 0;
538
+ if (!project || actorRole(actor, project.id) === void 0) throw new CollabError("Project not found", 404);
539
+ return project;
540
+ }
541
+ need(actor, projectId, role, action) {
542
+ const held = actorRole(actor, projectId);
543
+ if (!roleAtLeast(held, role)) throw new CollabError(`A project ${role} role is required to ${action}`, 403);
544
+ return held;
545
+ }
546
+ /** Progress, heartbeats, finishes and checkpoints come only from the machine executing the attempt: the owner process for "local" attempts, otherwise the very runner credential the attempt was claimed with, used by the same account. */
547
+ assertAttemptRunner(attempt, actor) {
548
+ const ok = actor.kind === "owner" ? attempt.runnerId === RUNNER_LOCAL && attempt.user.userId === "owner" : actor.via === "runner" && actor.runnerId !== void 0 && actor.runnerId === attempt.runnerId && actor.userId === attempt.user.userId;
549
+ if (!ok) throw new CollabError("Only the runner executing this attempt may report on it", 403);
550
+ }
551
+ cas(task, revision) {
552
+ if (revision === void 0 || revision === null) throw new CollabError("Send the task's current revision with the change");
553
+ if (!integer(revision) || revision !== task.revision) throw new CollabError("Task changed; reload before saving", 409);
554
+ }
555
+ resolveWho(projectId, userId, minRole, label) {
556
+ const id = validateUserRef(userId, label);
557
+ if (id === "owner") return { userId: "owner", displayName: this.ownerName };
558
+ const member = this.options.members().find((entry) => entry.userId === id);
559
+ if (!member || !member.enabled || !Object.hasOwn(member.roles, projectId)) throw new CollabError(`Choose the ${label} from the project's members`);
560
+ if (!roleAtLeast(member.roles[projectId], minRole)) throw new CollabError(`The ${label} needs at least the ${minRole} role in this project`);
561
+ return { userId: member.userId, displayName: member.displayName };
562
+ }
563
+ assertRepository(board, projectId, key) {
564
+ const repositories = Object.hasOwn(board.projects, projectId) ? board.projects[projectId].repositories : [];
565
+ if (!repositories.some((repo) => repo.key === key)) throw new CollabError(repositories.length ? `Choose the repository from this project's keys: ${repositories.map((repo) => repo.key).join(", ")}` : "Define this project's repository keys in People first");
566
+ }
567
+ dependencies(board, projectId, ids, self) {
568
+ for (const id of ids) if (id === self || !board.tasks.some((task) => task.id === id && task.projectId === projectId)) throw new CollabError("Choose the tasks this one depends on from the board");
569
+ return ids;
570
+ }
571
+ stakeholders(task) {
572
+ const ids = task.collaborators.map((who2) => who2.userId);
573
+ if (task.owner) ids.unshift(task.owner.userId);
574
+ if (task.reviewer) ids.push(task.reviewer.userId);
575
+ return ids;
576
+ }
577
+ /** Every change to a task bumps the task and board revisions and leaves an audit line; progress and heartbeats bypass this on purpose. */
578
+ commit(draft, task, by, action, detail, now) {
579
+ task.revision++;
580
+ task.updatedAt = now;
581
+ recordAudit(task, by, action, detail, now);
582
+ draft.board.revision++;
583
+ draft.dirty.add("board");
584
+ draft.events.push({ kind: "task", taskId: task.id, projectId: task.projectId });
585
+ }
586
+ /** A system line in the discussion; skipped rather than failing the mutation when the discussion is full. */
587
+ system(task, by, text2, now, kind = "system") {
588
+ if (task.discussion.length < MAX.discussion) task.discussion.push({ id: newId("comment", this.random), by, text: text2, mentions: [], at: now, kind });
589
+ }
590
+ /** Inbox items for everyone named except the actor; each user keeps at most 500 (oldest read first), the hub at most 5000. */
591
+ notify(draft, task, actor, userIds, kind, text2) {
592
+ if (!this.options.projects().some((project) => project.id === task.projectId)) return;
593
+ const members = /* @__PURE__ */ new Set(["owner", ...this.membersOf(task.projectId).map((member) => member.userId)]);
594
+ const targets = new Set([...userIds].filter((id) => members.has(id)));
595
+ targets.delete(actor.userId);
596
+ if (!targets.size) return;
597
+ const now = this.now(), items = draft.inbox.items;
598
+ for (const userId of targets) {
599
+ items.push({ id: newId("inbox", this.random), userId, kind, taskId: task.id, taskTitle: task.title, text: truncateText(text2, MAX.inboxText), by: whoOf(actor), at: now });
600
+ const mine = items.filter((item) => item.userId === userId);
601
+ while (mine.length > MAX.inboxPerUser) {
602
+ const victim = mine.find((item) => item.readAt !== void 0) ?? mine[0];
603
+ items.splice(items.indexOf(victim), 1);
604
+ mine.splice(mine.indexOf(victim), 1);
605
+ }
606
+ }
607
+ while (items.length > MAX.inboxTotal) {
608
+ const victim = items.find((item) => item.readAt !== void 0) ?? items[0];
609
+ items.splice(items.indexOf(victim), 1);
610
+ }
611
+ draft.dirty.add("inbox");
612
+ draft.events.push({ kind: "inbox", userIds: [...targets] });
613
+ }
614
+ // ---- Persistence
615
+ async loadDocument(name, validate, empty) {
616
+ const file = join(this.dir, name);
617
+ let raw;
618
+ try {
619
+ raw = await readDocument(file);
620
+ } catch (error) {
621
+ this.degraded.add(name);
622
+ if (error instanceof InvalidDocument) return this.setAside(file, error.message, empty);
623
+ this.readable = false;
624
+ logError(`Couldn't read ${file}; the team board is unavailable and the file is left untouched until it can be read`, error);
625
+ return empty();
626
+ }
627
+ if (raw === void 0) return empty();
628
+ try {
629
+ return validate(raw);
630
+ } catch (error) {
631
+ this.degraded.add(name);
632
+ return this.setAside(file, error instanceof Error ? error.message : String(error), empty);
633
+ }
634
+ }
635
+ async setAside(file, why, empty) {
636
+ const aside = `${file}.corrupt-${new Date(this.now()).toISOString().replace(/[:.]/g, "-")}`;
637
+ try {
638
+ await rename(file, aside);
639
+ logError(`${file} is unusable (${why}): moved it to ${aside}; the team board starts without it`);
640
+ } catch (error) {
641
+ this.readable = false;
642
+ logError(`${file} is unusable (${why}) and couldn't be moved aside; the team board is unavailable until it's fixed`, error);
643
+ }
644
+ return empty();
645
+ }
646
+ serialize(document, validate, name) {
647
+ try {
648
+ validate(document);
649
+ } catch (error) {
650
+ throw new CollabError(`Team storage refused to write an inconsistent ${name} document: ${error instanceof Error ? error.message : String(error)}`, 500);
651
+ }
652
+ const body = JSON.stringify(document);
653
+ if (Buffer.byteLength(body) > READ_LIMIT) throw new CollabError(`The team ${name} has reached its 16 MiB storage limit; delete finished tasks first`, 413);
654
+ return body;
655
+ }
656
+ async writeDocument(name, body) {
657
+ const file = join(this.dir, name);
658
+ try {
659
+ const info = await lstat(file);
660
+ if (!info.isFile()) throw new CollabError(`Refusing to replace ${file}: it is not a regular file`, 503);
661
+ } catch (error) {
662
+ if (error.code !== "ENOENT") throw error;
663
+ }
664
+ const temp = `${file}.${this.random(8).toString("hex")}.tmp`;
665
+ try {
666
+ const handle = await open(temp, "wx", 384);
667
+ try {
668
+ await handle.writeFile(body);
669
+ await handle.sync();
670
+ } finally {
671
+ await handle.close();
672
+ }
673
+ await rename(temp, file);
674
+ await chmod(file, 384);
675
+ if (process.platform !== "win32") {
676
+ const dir = await open(this.dir, "r");
677
+ try {
678
+ await dir.sync();
679
+ } finally {
680
+ await dir.close();
681
+ }
682
+ }
683
+ } finally {
684
+ await rm(temp, { force: true }).catch(() => {
685
+ });
686
+ }
687
+ }
688
+ draft() {
689
+ const state = this.state, cloned = /* @__PURE__ */ new Map();
690
+ let runners, inbox;
691
+ const draft = {
692
+ board: { ...state.board, projects: { ...state.board.projects }, tasks: [...state.board.tasks] },
693
+ dirty: /* @__PURE__ */ new Set(),
694
+ events: [],
695
+ after: [],
696
+ undo: [],
697
+ get runners() {
698
+ return runners ??= clone(state.runners);
699
+ },
700
+ get inbox() {
701
+ return inbox ??= clone(state.inbox);
702
+ },
703
+ task(id, dirty = "board") {
704
+ let task = cloned.get(id);
705
+ if (!task) {
706
+ const index = draft.board.tasks.findIndex((entry) => entry.id === id);
707
+ if (index < 0) return void 0;
708
+ task = clone(draft.board.tasks[index]);
709
+ draft.board.tasks[index] = task;
710
+ cloned.set(id, task);
711
+ }
712
+ draft.dirty.add(dirty);
713
+ return task;
714
+ },
715
+ snapshot: () => ({ board: draft.board, runners: runners ?? state.runners, inbox: inbox ?? state.inbox })
716
+ };
717
+ return draft;
718
+ }
719
+ /** The one mutation queue: apply to a draft, validate, write the touched documents (board last, as the commit point), swap, then announce. A failed write leaves memory and the announced state untouched. A mutation that touched telemetry alone skips the write: it is swapped in and announced, and the board is flagged for the next durable change or the write-behind timer. */
720
+ change(fn) {
721
+ const run = this.queue.catch(() => void 0).then(async () => {
722
+ await this.load();
723
+ this.require();
724
+ const draft = this.draft();
725
+ const result = await fn(draft);
726
+ if (draft.dirty.has("telemetry") && draft.dirty.size === 1) {
727
+ this.state = draft.snapshot();
728
+ this.boardPending = true;
729
+ this.armFlush();
730
+ this.announce(draft);
731
+ return result;
732
+ }
733
+ if (this.runnersDirty) draft.dirty.add("runners");
734
+ if (this.boardPending || draft.dirty.delete("telemetry")) draft.dirty.add("board");
735
+ if (draft.dirty.size) {
736
+ const bodies = [];
737
+ if (draft.dirty.has("runners")) bodies.push(["runners.json", this.serialize(draft.runners, validateRunnersDocument, "runners")]);
738
+ if (draft.dirty.has("inbox")) bodies.push(["inbox.json", this.serialize(draft.inbox, validateInboxDocument, "inbox")]);
739
+ if (draft.dirty.has("board")) bodies.push(["board.json", this.serialize(draft.board, (raw) => validateBoardDocument(raw, this.validated), "board")]);
740
+ try {
741
+ for (const [name, body] of bodies) await this.writeDocument(name, body);
742
+ } catch (error) {
743
+ for (const undo of draft.undo) await undo().catch(() => void 0);
744
+ throw error instanceof CollabError ? error : new CollabError(`Team storage could not be written: ${error instanceof Error ? error.message : String(error)}`, 503);
745
+ }
746
+ this.state = draft.snapshot();
747
+ if (draft.dirty.has("runners")) this.runnersDirty = false;
748
+ if (draft.dirty.has("board")) this.boardPending = false;
749
+ this.announce(draft);
750
+ for (const task of draft.after) await task().catch((error) => logError("Team storage could not finish a cleanup after a durable change", error));
751
+ }
752
+ return result;
753
+ });
754
+ this.queue = run;
755
+ return run;
756
+ }
757
+ /** Each distinct event once, in order; listeners that throw cannot undo a durable change. */
758
+ announce(draft) {
759
+ const seen = /* @__PURE__ */ new Set();
760
+ for (const event of draft.events) {
761
+ const key = JSON.stringify(event);
762
+ if (!seen.has(key)) {
763
+ seen.add(key);
764
+ this.emit(event);
765
+ }
766
+ }
767
+ }
768
+ /** Wake project-filtered team views after a durable host queue or policy change. */
769
+ hostExecutionChanged(projectId, taskId) {
770
+ if (projectId) this.emit({ kind: "task", projectId, taskId });
771
+ else for (const project of this.options.projects()) this.emit({ kind: "task", projectId: project.id });
772
+ }
773
+ emit(event) {
774
+ const full = { ...event, seq: ++this.seq };
775
+ this.ring.push(full);
776
+ if (this.ring.length > RING) this.ring.splice(0, this.ring.length - RING);
777
+ for (const listener of this.listeners) {
778
+ try {
779
+ listener(full);
780
+ } catch {
781
+ }
782
+ }
783
+ }
784
+ // ---- Tasks
785
+ async createTask(actor, input) {
786
+ const data = validateTaskInput(input);
787
+ return this.change((draft) => {
788
+ const now = this.now(), project = this.project(data.projectId, actor), by = whoOf(actor);
789
+ this.need(actor, project.id, "contributor", "create tasks");
790
+ this.assertRepository(draft.board, project.id, data.repository);
791
+ const task = { id: newTaskId(this.random), projectId: project.id, repository: data.repository, title: data.title, goal: data.goal, acceptance: data.acceptance, status: "planned", collaborators: data.collaborators.map((id) => this.resolveWho(project.id, id, "viewer", "collaborator")), dependsOn: this.dependencies(draft.board, project.id, data.dependsOn, void 0), labels: data.labels, revision: 1, createdAt: now, updatedAt: now, createdBy: by, attempts: [], checkpoints: [], decisions: [], discussion: [], instructions: [], reviews: [], handoffs: [], audit: [] };
792
+ if (data.baseRef) task.baseRef = data.baseRef;
793
+ if (data.reviewer) task.reviewer = this.resolveWho(project.id, data.reviewer, "reviewer", "reviewer");
794
+ if (data.owner) {
795
+ const owner = this.resolveWho(project.id, data.owner, "contributor", "owner");
796
+ assertCanChangeOwner(task, actor, owner);
797
+ assignOwner(task, owner, now);
798
+ }
799
+ if (data.status !== "planned") transition(task, data.status, actor, data.blocker, now);
800
+ recordAudit(task, by, "created", void 0, now);
801
+ pushBounded(draft.board.tasks, task, MAX.tasks, "tasks");
802
+ draft.board.revision++;
803
+ draft.dirty.add("board");
804
+ draft.events.push({ kind: "task", taskId: task.id, projectId: task.projectId }, { kind: "board", projectId: task.projectId });
805
+ if (task.owner) this.notify(draft, task, actor, [task.owner.userId], "assigned", `${actor.displayName} assigned you \u201C${task.title}\u201D`);
806
+ if (task.reviewer) this.notify(draft, task, actor, [task.reviewer.userId], "review-requested", `${actor.displayName} asked you to review \u201C${task.title}\u201D`);
807
+ if (task.status === "blocked") this.notify(draft, task, actor, this.stakeholders(task), "blocked", `\u201C${task.title}\u201D is blocked: ${task.blocker}`);
808
+ return clone(task);
809
+ });
810
+ }
811
+ /** A partial save under the owner rules (assertCanChangeOwner/assignOwner) and the status rules (transition); `null` clears an optional field. */
812
+ async updateTask(actor, id, patch, revision) {
813
+ const changes = validateTaskPatch(patch);
814
+ return this.change((draft) => {
815
+ const now = this.now(), current = this.findTask(draft.board, actor, id);
816
+ this.need(actor, current.projectId, "contributor", "edit tasks");
817
+ this.cas(current, revision);
818
+ const task = draft.task(current.id), changed = [], before = { owner: task.owner?.userId, reviewer: task.reviewer?.userId, status: task.status };
819
+ if (changes.repository !== void 0 && changes.repository !== task.repository) {
820
+ this.assertRepository(draft.board, task.projectId, changes.repository);
821
+ task.repository = changes.repository;
822
+ changed.push("repository");
823
+ }
824
+ if (changes.title !== void 0 && changes.title !== task.title) {
825
+ task.title = changes.title;
826
+ changed.push("title");
827
+ }
828
+ if (changes.goal !== void 0 && changes.goal !== task.goal) {
829
+ task.goal = changes.goal;
830
+ changed.push("goal");
831
+ }
832
+ if (changes.acceptance !== void 0 && changes.acceptance !== task.acceptance) {
833
+ task.acceptance = changes.acceptance;
834
+ changed.push("acceptance");
835
+ }
836
+ if (changes.labels !== void 0) {
837
+ task.labels = changes.labels;
838
+ changed.push("labels");
839
+ }
840
+ if (changes.dependsOn !== void 0) {
841
+ task.dependsOn = this.dependencies(draft.board, task.projectId, changes.dependsOn, task.id);
842
+ changed.push("dependencies");
843
+ }
844
+ if (changes.baseRef !== void 0) {
845
+ if (changes.baseRef === null) delete task.baseRef;
846
+ else task.baseRef = changes.baseRef;
847
+ changed.push("base");
848
+ }
849
+ if (changes.collaborators !== void 0) {
850
+ task.collaborators = changes.collaborators.map((userId) => this.resolveWho(task.projectId, userId, "viewer", "collaborator"));
851
+ changed.push("collaborators");
852
+ }
853
+ if (changes.reviewer !== void 0) {
854
+ if (changes.reviewer === null) delete task.reviewer;
855
+ else task.reviewer = this.resolveWho(task.projectId, changes.reviewer, "reviewer", "reviewer");
856
+ changed.push("reviewer");
857
+ }
858
+ if (changes.owner !== void 0) {
859
+ const next = changes.owner === null ? void 0 : this.resolveWho(task.projectId, changes.owner, "contributor", "owner");
860
+ assertCanChangeOwner(task, actor, next);
861
+ assignOwner(task, next, now);
862
+ changed.push("owner");
863
+ }
864
+ if (changes.status !== void 0 || changes.blocker !== void 0) {
865
+ transition(task, changes.status ?? task.status, actor, changes.blocker, now);
866
+ changed.push(changes.status !== void 0 ? "status" : "blocker");
867
+ }
868
+ this.commit(draft, task, whoOf(actor), "updated", changed.join(", ") || void 0, now);
869
+ if (task.owner && task.owner.userId !== before.owner) this.notify(draft, task, actor, [task.owner.userId], "assigned", `${actor.displayName} assigned you \u201C${task.title}\u201D`);
870
+ if (task.reviewer && task.reviewer.userId !== before.reviewer) this.notify(draft, task, actor, [task.reviewer.userId], "review-requested", `${actor.displayName} asked you to review \u201C${task.title}\u201D`);
871
+ if (task.status === "blocked" && before.status !== "blocked") this.notify(draft, task, actor, this.stakeholders(task), "blocked", `\u201C${task.title}\u201D is blocked: ${task.blocker}`);
872
+ return clone(task);
873
+ });
874
+ }
875
+ async deleteTask(actor, id, revision) {
876
+ return this.change((draft) => {
877
+ const now = this.now(), task = this.findTask(draft.board, actor, id);
878
+ this.need(actor, task.projectId, "maintainer", "delete tasks");
879
+ this.cas(task, revision);
880
+ const busy = busyAttempt(task);
881
+ if (busy) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before deleting the task`, 409);
882
+ draft.board.tasks = draft.board.tasks.filter((entry) => entry.id !== task.id);
883
+ draft.board.revision++;
884
+ draft.dirty.add("board");
885
+ draft.events.push({ kind: "task", taskId: task.id, projectId: task.projectId }, { kind: "board", projectId: task.projectId });
886
+ for (const other of draft.board.tasks) if (other.dependsOn.includes(task.id)) {
887
+ const dependent = draft.task(other.id);
888
+ dependent.dependsOn = dependent.dependsOn.filter((dep) => dep !== task.id);
889
+ this.commit(draft, dependent, whoOf(actor), "dependency-removed", task.title, now);
890
+ }
891
+ const affected = /* @__PURE__ */ new Set(), items = draft.inbox.items.filter((item) => {
892
+ if (item.taskId !== task.id) return true;
893
+ affected.add(item.userId);
894
+ return false;
895
+ });
896
+ if (affected.size) {
897
+ draft.inbox.items = items;
898
+ draft.dirty.add("inbox");
899
+ draft.events.push({ kind: "inbox", userIds: [...affected] });
900
+ }
901
+ const files = task.checkpoints.filter((checkpoint) => checkpoint.bundle === "available").map((checkpoint) => this.bundleFile(checkpoint.id));
902
+ if (files.length) draft.after.push(async () => {
903
+ for (const file of files) await rm(file, { force: true });
904
+ });
905
+ return { deleted: true };
906
+ });
907
+ }
908
+ /** Repository keys are what every teammate maps to a clone, so a key still used by a task cannot disappear. The board revision is the CAS here. */
909
+ async setRepositories(actor, projectId, repositories, revision) {
910
+ const refs = validateRepoRefs(repositories);
911
+ return this.change((draft) => {
912
+ const project = this.project(projectId, actor);
913
+ this.need(actor, project.id, "maintainer", "define repositories");
914
+ if (revision === void 0 || revision === null) throw new CollabError("Send the board's current revision with the change");
915
+ if (!integer(revision) || revision !== draft.board.revision) throw new CollabError("Team board changed; reload before saving", 409);
916
+ const keys = new Set(refs.map((repo) => repo.key)), used = draft.board.tasks.find((task) => task.projectId === project.id && !keys.has(task.repository));
917
+ if (used) throw new CollabError(`Task \u201C${used.title}\u201D still uses repository ${used.repository}; move it to another repository before removing that key`, 409);
918
+ draft.board.projects[project.id] = { repositories: refs };
919
+ draft.board.revision++;
920
+ draft.dirty.add("board");
921
+ draft.events.push({ kind: "board", projectId: project.id });
922
+ return this.projectView(draft.board, project, actor, this.onlineUsers(this.now()));
923
+ });
924
+ }
925
+ async setOwnerName(actor, name) {
926
+ return this.change((draft) => {
927
+ if (actor.kind !== "owner") throw new CollabError("Only the hub owner may change the owner's display name", 403);
928
+ const label = validateText(name, MAX.displayName, "display name", { required: true, singleLine: true });
929
+ draft.board.ownerName = label;
930
+ draft.board.revision++;
931
+ draft.dirty.add("board");
932
+ draft.events.push({ kind: "board" });
933
+ return { ownerName: label };
934
+ });
935
+ }
936
+ // ---- Discussion, decisions, reviews, instructions
937
+ async comment(actor, taskId, text2) {
938
+ return this.change((draft) => {
939
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
940
+ this.need(actor, current.projectId, "reviewer", "comment");
941
+ const body = validateText(text2, MAX.text, "comment", { required: true });
942
+ const task = draft.task(current.id), mentions = mentionsIn(body, this.mentionTargets(task.projectId));
943
+ pushBounded(task.discussion, { id: newId("comment", this.random), by: whoOf(actor), text: body, mentions, at: now, kind: "comment" }, MAX.discussion, "comments");
944
+ this.commit(draft, task, whoOf(actor), "commented", truncateText(body, 120), now);
945
+ this.notify(draft, task, actor, mentions, "mention", `${actor.displayName} mentioned you: ${body}`);
946
+ this.notify(draft, task, actor, this.stakeholders(task).filter((id) => !mentions.includes(id)), "comment", `${actor.displayName} commented: ${body}`);
947
+ return clone(task);
948
+ });
949
+ }
950
+ async decide(actor, taskId, text2) {
951
+ return this.change((draft) => {
952
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
953
+ this.need(actor, current.projectId, "contributor", "add decisions");
954
+ const body = validateText(text2, MAX.decision, "decision", { required: true });
955
+ const task = draft.task(current.id), mentions = mentionsIn(body, this.mentionTargets(task.projectId));
956
+ pushBounded(task.decisions, { id: newId("decision", this.random), by: whoOf(actor), text: body, at: now }, MAX.decisions, "decisions");
957
+ this.commit(draft, task, whoOf(actor), "decided", truncateText(body, 120), now);
958
+ this.notify(draft, task, actor, mentions, "mention", `${actor.displayName} mentioned you in a decision: ${body}`);
959
+ return clone(task);
960
+ });
961
+ }
962
+ async requestReview(actor, taskId, reviewerUserId, revision) {
963
+ return this.change((draft) => {
964
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
965
+ this.need(actor, current.projectId, "contributor", "request reviews");
966
+ this.cas(current, revision);
967
+ const task = draft.task(current.id), reviewer = this.resolveWho(task.projectId, reviewerUserId, "reviewer", "reviewer");
968
+ task.reviewer = reviewer;
969
+ this.system(task, whoOf(actor), `${actor.displayName} asked ${reviewer.displayName} to review this task`, now, "review-request");
970
+ this.commit(draft, task, whoOf(actor), "review-requested", reviewer.displayName, now);
971
+ this.notify(draft, task, actor, [reviewer.userId], "review-requested", `${actor.displayName} asked you to review \u201C${task.title}\u201D`);
972
+ return clone(task);
973
+ });
974
+ }
975
+ /** A review names an existing checkpoint of the task; reviews of anything but the latest checkpoint are stale, which reviewState ignores. */
976
+ async review(actor, taskId, input, revision) {
977
+ return this.change((draft) => {
978
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
979
+ this.need(actor, current.projectId, "reviewer", "record reviews");
980
+ this.cas(current, revision);
981
+ if (!object(input)) throw new CollabError("Send the review as { checkpointId, verdict, text }");
982
+ const checkpoint = current.checkpoints.find((entry) => entry.id === input.checkpointId);
983
+ if (!checkpoint) throw new CollabError("Checkpoint not found", 404);
984
+ if (input.verdict !== "approve" && input.verdict !== "request-changes") throw new CollabError('Choose a verdict: "approve" or "request-changes"');
985
+ const body = validateText(input.text, MAX.text, "review");
986
+ const task = draft.task(current.id), latest = latestCheckpoint(task), mentions = mentionsIn(body, this.mentionTargets(task.projectId));
987
+ pushBounded(task.reviews, { id: newId("review", this.random), checkpointId: checkpoint.id, commit: checkpoint.commit, by: whoOf(actor), verdict: input.verdict, text: body, at: now, stale: checkpoint.id !== latest.id }, MAX.reviews, "reviews");
988
+ for (const entry of task.reviews) entry.stale = entry.checkpointId !== latest.id;
989
+ this.commit(draft, task, whoOf(actor), "reviewed", `${input.verdict} on ${short(checkpoint.commit)}`, now);
990
+ this.notify(draft, task, actor, mentions, "mention", `${actor.displayName} mentioned you in a review: ${body}`);
991
+ if (task.owner) this.notify(draft, task, actor, [task.owner.userId], "review-recorded", `${actor.displayName} ${input.verdict === "approve" ? "approved" : "requested changes on"} \u201C${task.title}\u201D${body ? `: ${body}` : ""}`);
992
+ return clone(task);
993
+ });
994
+ }
995
+ /** "Send to agent": the task owner's own instructions are approved at once; anyone else's wait for the owner (or a maintainer). */
996
+ async proposeInstruction(actor, taskId, text2) {
997
+ return this.change((draft) => {
998
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
999
+ this.need(actor, current.projectId, "contributor", "send instructions to the agent");
1000
+ const body = validateText(text2, MAX.text, "instruction", { required: true });
1001
+ const task = draft.task(current.id), by = whoOf(actor), approved = isTaskOwner(task, actor), instruction = { id: newId("instruction", this.random), by, text: body, at: now, state: approved ? "approved" : "proposed" };
1002
+ if (approved) {
1003
+ instruction.decidedBy = by;
1004
+ instruction.decidedAt = now;
1005
+ }
1006
+ pushBounded(task.instructions, instruction, MAX.instructions, "instructions");
1007
+ this.commit(draft, task, by, approved ? "instruction-approved" : "instruction-proposed", truncateText(body, 120), now);
1008
+ this.notify(draft, task, actor, mentionsIn(body, this.mentionTargets(task.projectId)), "mention", `${actor.displayName} mentioned you in an instruction: ${body}`);
1009
+ if (!approved && task.owner) this.notify(draft, task, actor, [task.owner.userId], "instruction-proposed", `${actor.displayName} proposed an instruction for the agent on \u201C${task.title}\u201D: ${body}`);
1010
+ return clone(task);
1011
+ });
1012
+ }
1013
+ async decideInstruction(actor, taskId, instructionId, action) {
1014
+ return this.change((draft) => {
1015
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
1016
+ if (!isTaskOwner(current, actor) && !roleAtLeast(actorRole(actor, current.projectId), "maintainer")) throw new CollabError("Only the task owner or a project maintainer may approve or decline instructions", 403);
1017
+ if (action !== "approve" && action !== "decline") throw new CollabError('Choose an action: "approve" or "decline"');
1018
+ const existing = current.instructions.find((entry) => entry.id === instructionId);
1019
+ if (!existing) throw new CollabError("Instruction not found", 404);
1020
+ if (existing.state !== "proposed") throw new CollabError(`This instruction was already ${existing.state}`, 409);
1021
+ const task = draft.task(current.id), instruction = task.instructions.find((entry) => entry.id === instructionId), by = whoOf(actor);
1022
+ instruction.state = action === "approve" ? "approved" : "declined";
1023
+ instruction.decidedBy = by;
1024
+ instruction.decidedAt = now;
1025
+ this.commit(draft, task, by, `instruction-${instruction.state}`, truncateText(instruction.text, 120), now);
1026
+ return clone(task);
1027
+ });
1028
+ }
1029
+ /** The runner records delivery so a reconnect never hands the agent the same instruction twice. */
1030
+ async markInstructionDelivered(actor, taskId, instructionId, attemptId) {
1031
+ return this.change((draft) => {
1032
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId), attempt = current.attempts.find((entry) => entry.id === attemptId);
1033
+ if (!attempt) throw new CollabError("Attempt not found", 404);
1034
+ this.assertAttemptRunner(attempt, actor);
1035
+ const existing = current.instructions.find((entry) => entry.id === instructionId);
1036
+ if (!existing) throw new CollabError("Instruction not found", 404);
1037
+ if (existing.state === "delivered" && existing.attemptId === attempt.id) return clone(current);
1038
+ if (existing.state !== "approved") throw new CollabError(`Only approved instructions can be delivered; this one is ${existing.state}`, 409);
1039
+ const task = draft.task(current.id), instruction = task.instructions.find((entry) => entry.id === instructionId);
1040
+ instruction.state = "delivered";
1041
+ instruction.attemptId = attempt.id;
1042
+ this.commit(draft, task, whoOf(actor), "instruction-delivered", attempt.runnerName, now);
1043
+ return clone(task);
1044
+ });
1045
+ }
1046
+ // ---- Attempts
1047
+ /** Only the task owner's own machine claims: the owner process with runner id "local", or a member's runner credential naming itself. Idempotent on attemptId, so a runner that crashed after claiming can resume safely. */
1048
+ async claimAttempt(actor, taskId, input) {
1049
+ return this.change((draft) => {
1050
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
1051
+ this.need(actor, current.projectId, "contributor", "run tasks");
1052
+ if (!object(input)) throw new CollabError("Send the attempt as { attemptId, runnerId, runnerName, baseCommit, fromCheckpointId?, revision }");
1053
+ if (typeof input.attemptId !== "string" || !ATTEMPT_ID.test(input.attemptId)) throw new CollabError("Send the attempt id as 32 lowercase hexadecimal characters");
1054
+ const attemptId = input.attemptId;
1055
+ const runnerName = validateText(input.runnerName, MAX.runnerName, "runner name", { required: true, singleLine: true }), baseCommit = validateCommit(input.baseCommit, "base commit");
1056
+ const fromCheckpointId = input.fromCheckpointId === void 0 || input.fromCheckpointId === null ? void 0 : input.fromCheckpointId;
1057
+ if (fromCheckpointId !== void 0 && (typeof fromCheckpointId !== "string" || !CHECKPOINT_ID.test(fromCheckpointId))) throw new CollabError("Send the checkpoint id as 32 lowercase hexadecimal characters");
1058
+ if (!isTaskOwner(current, actor)) throw new CollabError("Only the task owner may start an attempt; take the task first", 403);
1059
+ const runnerId = this.claimRunnerId(actor, input.runnerId), existing = this.attemptById(draft.board, attemptId);
1060
+ if (existing) {
1061
+ if (existing.task.id === current.id && existing.attempt.runnerId === runnerId && existing.attempt.user.userId === actor.userId) return clone(current);
1062
+ throw new CollabError("That attempt id already belongs to another attempt; generate a new one", 409);
1063
+ }
1064
+ this.cas(current, input.revision);
1065
+ const busy = busyAttempt(current);
1066
+ if (busy) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before starting another`, 409);
1067
+ if (current.handoff?.state === "offered") throw new CollabError("Accept, decline or cancel the offered hand-over before starting an attempt", 409);
1068
+ if (current.status === "done") throw new CollabError("Move the task out of Done before starting an attempt", 409);
1069
+ if (fromCheckpointId !== void 0) {
1070
+ const checkpoint = current.checkpoints.find((entry) => entry.id === fromCheckpointId);
1071
+ if (!checkpoint) throw new CollabError("Checkpoint not found", 404);
1072
+ const latest = latestCheckpoint(current);
1073
+ if (checkpoint.id !== latest.id && !roleAtLeast(actorRole(actor, current.projectId), "maintainer")) throw new CollabError(`Continue from the latest checkpoint (${short(latest.commit)}); only a project maintainer may start from an older one`, 409);
1074
+ }
1075
+ const task = draft.task(current.id), attempt = { id: attemptId, taskId: task.id, runnerId, runnerName, user: whoOf(actor), state: "claimed", baseCommit, createdAt: now, lastSeq: 0, lastHeartbeatAt: now, progress: { updatedAt: now } };
1076
+ if (fromCheckpointId !== void 0) attempt.fromCheckpointId = fromCheckpointId;
1077
+ pushBounded(task.attempts, attempt, MAX.attempts, "attempts");
1078
+ if (task.status !== "working") transition(task, "working", actor, void 0, now);
1079
+ this.commit(draft, task, whoOf(actor), "attempt-claimed", `${runnerName} from ${short(baseCommit)}`, now);
1080
+ return clone(task);
1081
+ });
1082
+ }
1083
+ claimRunnerId(actor, runnerId) {
1084
+ if (actor.kind === "owner") {
1085
+ if (runnerId !== RUNNER_LOCAL) throw new CollabError(`The hub owner runs tasks in-process; send runnerId "${RUNNER_LOCAL}"`);
1086
+ return RUNNER_LOCAL;
1087
+ }
1088
+ if (actor.via !== "runner" || actor.runnerId === void 0) throw new CollabError("Start attempts from your own code-flow (Connections \u2192 Share access \u2192 Connect this computer), not from the browser", 403);
1089
+ if (runnerId !== actor.runnerId) throw new CollabError("The attempt's runnerId must be the runner id of the credential making the request", 403);
1090
+ return actor.runnerId;
1091
+ }
1092
+ /** Reports are ordered by `seq`; a stale or duplicate report is a no-op that returns the current attempt. Progress never bumps revisions, so an editing teammate is not conflicted by a running agent, and on an attempt already running it is telemetry (announced at once, written behind). A report that revives a paused, stopped or unknown attempt is a state change under the claim's rules: refused when the attempt's user no longer owns the task (403), on a Done task, while another attempt is busy on the task or while a hand-over is offered (409; so a stale Resume never puts two runs on one task or reopens a finished one), and written at once like the first report of a claimed attempt. */
1093
+ async attemptProgress(actor, attemptId, report) {
1094
+ const parsed = validateProgressReport(report, this.now());
1095
+ return this.change((draft) => {
1096
+ const now = this.now(), found = this.findAttempt(draft.board, actor, attemptId);
1097
+ this.assertAttemptRunner(found.attempt, actor);
1098
+ this.assertNotEnded(found.attempt);
1099
+ if (parsed.seq <= found.attempt.lastSeq) return { attempt: clone(found.attempt) };
1100
+ if (found.attempt.state !== "claimed" && found.attempt.state !== "running") {
1101
+ if (!isTaskOwner(found.task, actor)) throw new CollabError(ownerChangedMessage(found.task), 403);
1102
+ if (found.task.status === "done") throw new CollabError("Move the task out of Done before resuming the attempt", 409);
1103
+ const busy = busyAttempt(found.task);
1104
+ if (busy && busy.id !== found.attempt.id) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before resuming this one`, 409);
1105
+ if (found.task.handoff?.state === "offered") throw new CollabError("Accept, decline or cancel the offered hand-over before resuming the attempt", 409);
1106
+ }
1107
+ const task = draft.task(found.task.id, found.attempt.state === "running" ? "telemetry" : "board"), attempt = task.attempts.find((entry) => entry.id === found.attempt.id);
1108
+ attempt.progress = parsed.progress;
1109
+ attempt.lastSeq = parsed.seq;
1110
+ attempt.lastHeartbeatAt = now;
1111
+ attempt.state = "running";
1112
+ attempt.startedAt ??= now;
1113
+ delete attempt.endedAt;
1114
+ delete attempt.reason;
1115
+ if (parsed.runId !== void 0) attempt.runId = parsed.runId;
1116
+ if (parsed.progress.head) attempt.head = parsed.progress.head;
1117
+ if (parsed.progress.branch) attempt.branch = parsed.progress.branch;
1118
+ draft.events.push({ kind: "task", taskId: task.id, projectId: task.projectId });
1119
+ return { attempt: clone(attempt) };
1120
+ });
1121
+ }
1122
+ async heartbeat(actor, attemptId) {
1123
+ return this.change((draft) => {
1124
+ const found = this.findAttempt(draft.board, actor, attemptId);
1125
+ this.assertAttemptRunner(found.attempt, actor);
1126
+ this.assertNotEnded(found.attempt);
1127
+ const task = draft.task(found.task.id, "telemetry"), attempt = task.attempts.find((entry) => entry.id === found.attempt.id);
1128
+ attempt.lastHeartbeatAt = this.now();
1129
+ draft.events.push({ kind: "task", taskId: task.id, projectId: task.projectId });
1130
+ return { attempt: clone(attempt), instructions: clone(task.instructions.filter((entry) => entry.state === "approved")) };
1131
+ });
1132
+ }
1133
+ assertNotEnded(attempt) {
1134
+ if (TERMINAL.includes(attempt.state)) throw new CollabError(`This attempt ${attempt.state === "superseded" ? "was superseded by a change of owner or recovery" : `already ended as ${attempt.state}`}; start a new attempt instead`, 409);
1135
+ }
1136
+ /** Idempotent for the runner's retries: finishing again with the same state returns the attempt unchanged. paused/stopped/unknown attempts can be resumed, finished/failed cannot. */
1137
+ async finishAttempt(actor, attemptId, input) {
1138
+ return this.change((draft) => {
1139
+ const now = this.now(), found = this.findAttempt(draft.board, actor, attemptId);
1140
+ this.assertAttemptRunner(found.attempt, actor);
1141
+ if (!object(input) || !FINISH_STATES.includes(input.state)) throw new CollabError('Choose the final state: "finished", "failed", "stopped", "paused" or "unknown"');
1142
+ const state = input.state;
1143
+ const head = input.head === void 0 || input.head === null ? void 0 : validateCommit(input.head, "head"), summary = validateText(input.summary, MAX.summary, "summary"), reason = validateText(input.reason, MAX.reason, "reason");
1144
+ if (found.attempt.state === state) return { attempt: clone(found.attempt) };
1145
+ this.assertNotEnded(found.attempt);
1146
+ const task = draft.task(found.task.id), attempt = task.attempts.find((entry) => entry.id === found.attempt.id);
1147
+ attempt.state = state;
1148
+ if (state === "unknown") delete attempt.endedAt;
1149
+ else attempt.endedAt = now;
1150
+ attempt.lastHeartbeatAt = now;
1151
+ if (head) attempt.head = head;
1152
+ if (summary) {
1153
+ attempt.progress.summary = summary;
1154
+ attempt.progress.updatedAt = now;
1155
+ }
1156
+ if (reason) attempt.reason = reason;
1157
+ this.commit(draft, task, whoOf(actor), `attempt-${state}`, truncateText(reason || summary, 200) || void 0, now);
1158
+ const label = state === "finished" ? "finished" : state === "failed" ? "failed" : state === "stopped" ? "stopped at a limit" : state === "paused" ? "paused" : "lost contact";
1159
+ if (task.owner) this.notify(draft, task, actor, [task.owner.userId], state === "failed" || state === "unknown" ? "attempt-failed" : "attempt-finished", `The attempt on ${attempt.runnerName} ${label}${reason ? `: ${reason}` : ""}`);
1160
+ return { attempt: clone(attempt) };
1161
+ });
1162
+ }
1163
+ /** A human recovery decision, never a heartbeat timeout: confirm the original execution has stopped before releasing its reservation. */
1164
+ async recoverAttempt(actor, attemptId, input) {
1165
+ return this.change((draft) => {
1166
+ const found = this.findAttempt(draft.board, actor, attemptId), role = actorRole(actor, found.task.projectId);
1167
+ if (!(isTaskOwner(found.task, actor) && roleAtLeast(role, "contributor")) && !roleAtLeast(role, "maintainer")) throw new CollabError("Only the task owner or a project maintainer may recover an attempt", 403);
1168
+ this.cas(found.task, input.revision);
1169
+ if (found.attempt.state !== "unknown") throw new CollabError("Only an unknown attempt can be recovered; reconcile or stop its runner first", 409);
1170
+ if (input.confirmedStopped !== true) throw new CollabError("Confirm that the original agent has stopped before releasing this task");
1171
+ const reason = validateText(input.reason, MAX.reason, "recovery reason", { required: true }), now = this.now();
1172
+ const task = draft.task(found.task.id), attempt = task.attempts.find((entry) => entry.id === attemptId);
1173
+ attempt.state = "superseded";
1174
+ attempt.endedAt = now;
1175
+ attempt.reason = reason;
1176
+ this.commit(draft, task, whoOf(actor), "attempt-recovered", reason, now);
1177
+ this.system(task, whoOf(actor), `${actor.displayName} confirmed execution on ${attempt.runnerName} has stopped: ${reason}`, now);
1178
+ return clone(task);
1179
+ });
1180
+ }
1181
+ // ---- Checkpoints
1182
+ /** The manifest may name the checkpoint (`id`): the runner mints it before bundling, so the bundle's only ref, the board and the branch `team/<task>/<id8>` agree and every importing machine lands `refs/codeflow/team/<id>` under the id the board shows. Ids are random, so one already on the board (any task) is a stale retry and is refused; hub-side callers that send none get a generated id. */
1183
+ async addCheckpoint(actor, attemptId, input) {
1184
+ const manifest = validateCheckpointInput(input);
1185
+ return this.change((draft) => {
1186
+ const now = this.now(), found = this.findAttempt(draft.board, actor, attemptId);
1187
+ this.assertAttemptRunner(found.attempt, actor);
1188
+ if (found.attempt.state === "superseded") throw new CollabError("This attempt was superseded by a change of owner; its work can no longer be checkpointed", 409);
1189
+ if (manifest.id !== void 0 && draft.board.tasks.some((entry) => entry.checkpoints.some((checkpoint2) => checkpoint2.id === manifest.id))) throw new CollabError("A checkpoint with this id already exists; create the bundle again under a new id", 409);
1190
+ const task = draft.task(found.task.id), attempt = task.attempts.find((entry) => entry.id === found.attempt.id), id = manifest.id ?? newCheckpointId(this.random);
1191
+ const checkpoint = { id, taskId: task.id, attemptId: attempt.id, commit: manifest.commit, baseCommit: manifest.baseCommit, branch: manifest.branch, size: manifest.size, sha256: manifest.sha256, ref: `refs/codeflow/team/${id}`, createdBy: whoOf(actor), at: now, note: manifest.note, requirements: manifest.requirements, decisions: manifest.decisions, remaining: manifest.remaining, changedFiles: manifest.changedFiles, bundle: "pending" };
1192
+ if (manifest.testResults) checkpoint.testResults = manifest.testResults;
1193
+ pushBounded(task.checkpoints, checkpoint, MAX.checkpoints, "checkpoints");
1194
+ attempt.checkpointId = id;
1195
+ attempt.head = manifest.commit;
1196
+ for (const review of task.reviews) review.stale = review.checkpointId !== id;
1197
+ this.commit(draft, task, whoOf(actor), "checkpoint-added", short(manifest.commit), now);
1198
+ return clone(checkpoint);
1199
+ });
1200
+ }
1201
+ /** Streams to a temp file while hashing and counting (outside the mutation queue, so a slow upload blocks nobody), then moves it into place and marks the bundle available in one durable step. */
1202
+ async storeBundle(actor, checkpointId, stream, info) {
1203
+ await this.load();
1204
+ this.require();
1205
+ const found = this.findCheckpoint(this.state.board, actor, checkpointId);
1206
+ this.assertAttemptRunner(found.attempt, actor);
1207
+ this.assertPendingBundle(found.checkpoint);
1208
+ if (!object(info) || info.size !== found.checkpoint.size || typeof info.sha256 !== "string" || info.sha256.toLowerCase() !== found.checkpoint.sha256) throw new CollabError("The upload's size and sha256 must match the checkpoint manifest");
1209
+ await mkdir(this.bundleDir, { recursive: true, mode: 448 });
1210
+ const temp = `${this.bundleFile(found.checkpoint.id)}.${this.random(6).toString("hex")}.tmp`, hash = createHash("sha256");
1211
+ let received = 0;
1212
+ try {
1213
+ const handle = await open(temp, "wx", 384);
1214
+ try {
1215
+ for await (const chunk of stream) {
1216
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
1217
+ received += bytes.length;
1218
+ if (received > MAX.bundle) throw new CollabError(`Checkpoint bundles are limited to ${MAX.bundle / 1024 / 1024} MiB`, 413);
1219
+ if (received > found.checkpoint.size) throw new CollabError("The uploaded bundle is larger than the checkpoint manifest says; upload the file the manifest describes");
1220
+ hash.update(bytes);
1221
+ await handle.write(bytes);
1222
+ }
1223
+ await handle.sync();
1224
+ } finally {
1225
+ await handle.close();
1226
+ }
1227
+ if (received !== found.checkpoint.size || hash.digest("hex") !== found.checkpoint.sha256) throw new CollabError("The uploaded bundle does not match the checkpoint's size or sha256 digest; upload it again");
1228
+ } catch (error) {
1229
+ await rm(temp, { force: true }).catch(() => void 0);
1230
+ throw error instanceof CollabError ? error : new CollabError(`The bundle upload failed: ${error instanceof Error ? error.message : String(error)}; upload it again`);
1231
+ }
1232
+ let installed = false;
1233
+ try {
1234
+ return await this.change(async (draft) => {
1235
+ const now = this.now(), again = this.findCheckpoint(draft.board, actor, checkpointId);
1236
+ this.assertPendingBundle(again.checkpoint);
1237
+ const evicted = this.makeRoom(draft, whoOf(actor), now);
1238
+ const task = draft.task(again.task.id), checkpoint = task.checkpoints.find((entry) => entry.id === again.checkpoint.id), file = this.bundleFile(checkpoint.id);
1239
+ checkpoint.bundle = "available";
1240
+ try {
1241
+ await rename(temp, file);
1242
+ } catch (error) {
1243
+ logError(`A checkpoint bundle could not be moved into place as ${file}`, error);
1244
+ throw new CollabError("Team storage could not store the checkpoint bundle; check the team server's checkpoints folder (see server.log), then upload it again", 503);
1245
+ }
1246
+ installed = true;
1247
+ draft.undo.push(() => rm(file, { force: true }));
1248
+ if (evicted.length) draft.after.push(async () => {
1249
+ for (const old of evicted) await rm(old, { force: true });
1250
+ });
1251
+ this.commit(draft, task, whoOf(actor), "bundle-stored", `${checkpoint.size} bytes`, now);
1252
+ return clone(checkpoint);
1253
+ });
1254
+ } finally {
1255
+ if (!installed) await rm(temp, { force: true }).catch(() => void 0);
1256
+ }
1257
+ }
1258
+ assertPendingBundle(checkpoint) {
1259
+ if (checkpoint.bundle !== "pending") throw new CollabError(checkpoint.bundle === "available" ? "This checkpoint's bundle was already uploaded" : "This checkpoint's bundle was deleted; create a new checkpoint", 409);
1260
+ }
1261
+ /** Keeps at most 200 bundles on disk by retiring the oldest bundle of a done task; when none qualifies the upload fails with 507 instead of removing live work. */
1262
+ makeRoom(draft, by, now) {
1263
+ const available = [];
1264
+ for (const task of draft.board.tasks) for (const checkpoint of task.checkpoints) if (checkpoint.bundle === "available") available.push({ taskId: task.id, checkpointId: checkpoint.id, at: checkpoint.at, done: task.status === "done" && !(task.handoff?.state === "offered" && task.handoff.checkpointId === checkpoint.id) });
1265
+ const files = [];
1266
+ while (available.length >= MAX.bundlesOnDisk) {
1267
+ const victim = available.filter((entry) => entry.done).sort((a, b) => a.at - b.at)[0];
1268
+ if (!victim) throw new CollabError("Team checkpoint storage is full; delete checkpoints of finished tasks first", 507);
1269
+ const task = draft.task(victim.taskId), checkpoint = task.checkpoints.find((entry) => entry.id === victim.checkpointId);
1270
+ checkpoint.bundle = "deleted";
1271
+ this.commit(draft, task, by, "bundle-evicted", `${short(checkpoint.commit)} to make room`, now);
1272
+ available.splice(available.indexOf(victim), 1);
1273
+ files.push(this.bundleFile(checkpoint.id));
1274
+ }
1275
+ return files;
1276
+ }
1277
+ async bundlePath(actor, checkpointId) {
1278
+ await this.load();
1279
+ this.require();
1280
+ const found = this.findCheckpoint(this.state.board, actor, checkpointId);
1281
+ this.need(actor, found.task.projectId, "contributor", "download checkpoint bundles");
1282
+ if (found.checkpoint.bundle !== "available") throw new CollabError("Checkpoint bundle is not available", 404);
1283
+ const path = this.bundleFile(found.checkpoint.id), info = await lstat(path).catch(() => void 0);
1284
+ if (!info?.isFile() || info.size !== found.checkpoint.size) throw new CollabError("The checkpoint bundle is missing from the hub's storage; create a new checkpoint", 404);
1285
+ return { path, checkpoint: clone(found.checkpoint) };
1286
+ }
1287
+ async deleteCheckpoint(actor, id) {
1288
+ return this.change((draft) => {
1289
+ const now = this.now(), found = this.findCheckpoint(draft.board, actor, id);
1290
+ this.need(actor, found.task.projectId, "maintainer", "delete checkpoints");
1291
+ if (found.checkpoint.bundle === "deleted") return clone(found.checkpoint);
1292
+ if (found.task.handoff?.state === "offered" && found.task.handoff.checkpointId === found.checkpoint.id) throw new CollabError("Cancel the offered hand-over before deleting its checkpoint", 409);
1293
+ const task = draft.task(found.task.id), checkpoint = task.checkpoints.find((entry) => entry.id === found.checkpoint.id), hadFile = checkpoint.bundle === "available";
1294
+ checkpoint.bundle = "deleted";
1295
+ this.commit(draft, task, whoOf(actor), "checkpoint-deleted", short(checkpoint.commit), now);
1296
+ if (hadFile) {
1297
+ const file = this.bundleFile(checkpoint.id);
1298
+ draft.after.push(() => rm(file, { force: true }));
1299
+ }
1300
+ return clone(checkpoint);
1301
+ });
1302
+ }
1303
+ // ---- Hand-overs
1304
+ /** The task owner (or a maintainer) offers a contributor the task at an available checkpoint; nothing moves until the recipient accepts, so a declined or failed hand-over leaves the owner in place. */
1305
+ async offerHandoff(actor, taskId, input, revision) {
1306
+ return this.change((draft) => {
1307
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId), role = actorRole(actor, current.projectId);
1308
+ if (!(isTaskOwner(current, actor) && roleAtLeast(role, "contributor")) && !roleAtLeast(role, "maintainer")) throw new CollabError("Only the task owner or a project maintainer may hand the task over", 403);
1309
+ this.cas(current, revision);
1310
+ if (!object(input)) throw new CollabError("Send the hand-over as { to, checkpointId, note }");
1311
+ const to = this.resolveWho(current.projectId, input.to, "contributor", "teammate");
1312
+ if (to.userId === current.owner?.userId) throw new CollabError("Choose a teammate other than the task's current owner to hand over to");
1313
+ const busy = busyAttempt(current);
1314
+ if (busy) throw new CollabError(`Stop the attempt running on ${busy.runnerName} before handing the task over`, 409);
1315
+ if (current.handoff?.state === "offered") throw new CollabError(`A hand-over to ${current.handoff.to.displayName} is already offered; cancel it before offering another`, 409);
1316
+ const checkpoint = current.checkpoints.find((entry) => entry.id === input.checkpointId);
1317
+ if (!checkpoint) throw new CollabError("Checkpoint not found", 404);
1318
+ if (checkpoint.bundle !== "available") throw new CollabError("The checkpoint's bundle is not on the hub; upload it or choose another checkpoint", 409);
1319
+ const note = validateText(input.note, MAX.note, "note"), task = draft.task(current.id), by = whoOf(actor), handoff = { id: newId("handoff", this.random), from: by, to, checkpointId: checkpoint.id, note, state: "offered", offeredAt: now };
1320
+ task.handoff = handoff;
1321
+ this.system(task, by, `${actor.displayName} offered to hand this task over to ${to.displayName}`, now);
1322
+ this.commit(draft, task, by, "handoff-offered", to.displayName, now);
1323
+ this.notify(draft, task, actor, [to.userId], "handoff-offered", `${actor.displayName} offered you \u201C${task.title}\u201D${note ? `: ${note}` : ""}`);
1324
+ this.notify(draft, task, actor, mentionsIn(note, this.mentionTargets(task.projectId)).filter((id) => id !== to.userId), "mention", `${actor.displayName} mentioned you in a hand-over note: ${note}`);
1325
+ return clone(task);
1326
+ });
1327
+ }
1328
+ /** accept (recipient): the task changes hands through assignOwner, superseding paused or stopped attempts; decline (recipient) and cancel (sender or maintainer) leave the owner as is. */
1329
+ async respondHandoff(actor, taskId, handoffId, action, revision) {
1330
+ return this.change((draft) => {
1331
+ const now = this.now(), current = this.findTask(draft.board, actor, taskId);
1332
+ this.cas(current, revision);
1333
+ const offered = current.handoff;
1334
+ if (!offered || offered.id !== handoffId) {
1335
+ const past = current.handoffs.find((entry) => entry.id === handoffId);
1336
+ if (past) throw new CollabError(`This hand-over was already ${past.state}`, 409);
1337
+ throw new CollabError("Hand-over not found", 404);
1338
+ }
1339
+ if (action !== "accept" && action !== "decline" && action !== "cancel") throw new CollabError('Choose an action: "accept", "decline" or "cancel"');
1340
+ const role = actorRole(actor, current.projectId);
1341
+ if (action === "cancel") {
1342
+ if (offered.from.userId !== actor.userId && !roleAtLeast(role, "maintainer")) throw new CollabError("Only the teammate who offered the hand-over or a project maintainer may cancel it", 403);
1343
+ } else {
1344
+ if (offered.to.userId !== actor.userId) throw new CollabError("Only the teammate the task was offered to may accept or decline it", 403);
1345
+ if (action === "accept" && !roleAtLeast(role, "contributor")) throw new CollabError("A project contributor role is required to accept a hand-over", 403);
1346
+ }
1347
+ const task = draft.task(current.id), handoff = task.handoff, by = whoOf(actor);
1348
+ if (action === "accept") assignOwner(task, handoff.to, now);
1349
+ handoff.state = action === "accept" ? "accepted" : action === "decline" ? "declined" : "cancelled";
1350
+ handoff.respondedAt = now;
1351
+ delete task.handoff;
1352
+ pushBounded(task.handoffs, handoff, MAX.handoffs, "hand-overs", "drop-oldest");
1353
+ this.system(task, by, `${actor.displayName} ${handoff.state} the hand-over ${action === "cancel" ? `to ${handoff.to.displayName}` : `from ${handoff.from.displayName}`}`, now);
1354
+ this.commit(draft, task, by, `handoff-${handoff.state}`, action === "cancel" ? handoff.to.displayName : handoff.from.displayName, now);
1355
+ this.notify(draft, task, actor, [action === "cancel" ? handoff.to.userId : handoff.from.userId], `handoff-${handoff.state}`, `${actor.displayName} ${handoff.state} the hand-over of \u201C${task.title}\u201D`);
1356
+ return clone(task);
1357
+ });
1358
+ }
1359
+ // ---- Inbox and runners
1360
+ async markRead(actor, ids) {
1361
+ if (ids !== "all" && (!Array.isArray(ids) || ids.length > MAX.inboxPerUser || ids.some((id) => typeof id !== "string" || !PREFIXED_ID.test(id)))) throw new CollabError(`Send the inbox item ids to mark read as a list of at most ${MAX.inboxPerUser} ids, or "all"`);
1362
+ return this.change((draft) => {
1363
+ const now = this.now(), wanted = ids === "all" ? void 0 : new Set(ids);
1364
+ let changed = false;
1365
+ const visible = this.visibleInbox(draft.board, draft.inbox, actor);
1366
+ for (const item of visible) if (item.readAt === void 0 && (wanted === void 0 || wanted.has(item.id))) {
1367
+ item.readAt = now;
1368
+ changed = true;
1369
+ }
1370
+ if (changed) {
1371
+ draft.dirty.add("inbox");
1372
+ draft.events.push({ kind: "inbox", userIds: [actor.userId] });
1373
+ }
1374
+ return { unread: visible.filter((item) => item.readAt === void 0).length };
1375
+ });
1376
+ }
1377
+ /** Called after the connect handshake proved the password; the token is returned once and only its sha256 is stored. */
1378
+ async registerRunner(userId, name, address) {
1379
+ return this.change((draft) => {
1380
+ const now = this.now(), id = validateUserRef(userId, "account"), member = this.options.members().find((entry) => entry.userId === id);
1381
+ if (!member || !member.enabled || !Object.values(member.roles).some((role) => roleAtLeast(role, "contributor"))) throw new CollabError("A contributor role in at least one project is required to link a runner", 403);
1382
+ const label = validateText(name, MAX.runnerName, "runner name", { required: true, singleLine: true }), lastAddress = validateText(address, 128, "address", { singleLine: true });
1383
+ if (draft.runners.runners.filter((runner2) => runner2.revokedAt === void 0 && runner2.userId === id).length >= MAX.runnersPerUser) throw new CollabError(`At most ${MAX.runnersPerUser} runners may be linked per account; revoke one in Connections \u2192 Share access first`, 409);
1384
+ while (draft.runners.runners.length >= MAX.runners) {
1385
+ const revoked = draft.runners.runners.filter((runner2) => runner2.revokedAt !== void 0).sort((a, b) => a.revokedAt - b.revokedAt)[0];
1386
+ if (!revoked) throw new CollabError(`The hub has reached its limit of ${MAX.runners} linked runners; revoke unused runners first`, 409);
1387
+ draft.runners.runners.splice(draft.runners.runners.indexOf(revoked), 1);
1388
+ }
1389
+ const token = newRunnerToken(this.random), runner = { id: newRunnerId(this.random), userId: id, name: label, tokenHash: sha256Hex(token), createdAt: now, lastSeenAt: now };
1390
+ if (lastAddress) runner.lastAddress = lastAddress;
1391
+ draft.runners.runners.push(runner);
1392
+ draft.dirty.add("runners");
1393
+ draft.events.push({ kind: "runners", userIds: [id] });
1394
+ return { runner: runnerPublic(runner, now), token };
1395
+ });
1396
+ }
1397
+ /** A revoked runner can no longer report. Its attempts remain reserved as unknown until explicitly recovered. */
1398
+ async revokeRunner(actor, id) {
1399
+ return this.change((draft) => {
1400
+ const now = this.now(), runner = typeof id === "string" ? draft.runners.runners.find((entry) => entry.id === id) : void 0;
1401
+ if (!runner || runner.userId !== actor.userId && !this.seesAllRunners(actor)) throw new CollabError("Runner not found", 404);
1402
+ if (runner.revokedAt !== void 0) return runnerPublic(runner, now);
1403
+ runner.revokedAt = now;
1404
+ draft.dirty.add("runners");
1405
+ draft.events.push({ kind: "runners", userIds: [runner.userId] });
1406
+ for (const current of draft.board.tasks) for (const entry of current.attempts) if (entry.runnerId === runner.id && (entry.state === "claimed" || entry.state === "running")) {
1407
+ const task = draft.task(current.id), attempt = task.attempts.find((candidate) => candidate.id === entry.id);
1408
+ attempt.state = "unknown";
1409
+ delete attempt.endedAt;
1410
+ attempt.reason = `Runner ${runner.name} was revoked while this attempt was active`;
1411
+ this.commit(draft, task, whoOf(actor), "attempt-unknown", `runner ${runner.name} revoked`, now);
1412
+ }
1413
+ return runnerPublic(runner, now);
1414
+ });
1415
+ }
1416
+ /** Housekeeping: drops old revoked runners and read inbox items; silent attempts become unknown but retain their execution reservation. */
1417
+ async prune(now = this.now()) {
1418
+ return this.change((draft) => {
1419
+ const runners = draft.runners.runners.filter((runner) => runner.revokedAt === void 0 || now - runner.revokedAt < RETENTION_MS), prunedRunners = draft.runners.runners.length - runners.length;
1420
+ if (prunedRunners) {
1421
+ draft.runners.runners = runners;
1422
+ draft.dirty.add("runners");
1423
+ draft.events.push({ kind: "runners" });
1424
+ }
1425
+ const items = draft.inbox.items.filter((item) => item.readAt === void 0 || now - item.readAt < RETENTION_MS), prunedItems = draft.inbox.items.length - items.length;
1426
+ if (prunedItems) {
1427
+ draft.inbox.items = items;
1428
+ draft.dirty.add("inbox");
1429
+ }
1430
+ let attempts = 0;
1431
+ const by = { userId: "owner", displayName: this.ownerName };
1432
+ for (const current of draft.board.tasks) {
1433
+ const busy = busyAttempt(current);
1434
+ if (busy && busy.state !== "unknown" && now - busy.lastHeartbeatAt >= STALE_ATTEMPT_MS) {
1435
+ const task = draft.task(current.id), attempt = task.attempts.find((entry) => entry.id === busy.id);
1436
+ attempt.state = "unknown";
1437
+ delete attempt.endedAt;
1438
+ attempt.reason = "No heartbeat for 24 hours; reconcile the runner or confirm execution has stopped before recovering this task";
1439
+ this.commit(draft, task, by, "attempt-unknown", "no heartbeat for 24 hours", now);
1440
+ attempts++;
1441
+ }
1442
+ }
1443
+ return { runners: prunedRunners, inbox: prunedItems, attempts };
1444
+ });
1445
+ }
1446
+ }
1447
+ export {
1448
+ CollabStore,
1449
+ RUNNER_LOCAL,
1450
+ memberActor,
1451
+ ownerActor
1452
+ };