@ashulab/carrier 0.0.0-stage → 0.1.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 (193) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +265 -2
  3. package/dist/chat/claude.js +367 -0
  4. package/dist/chat/codex.js +375 -0
  5. package/dist/chat/config.js +61 -0
  6. package/dist/chat/db.js +9 -0
  7. package/dist/chat/fake.js +203 -0
  8. package/dist/chat/folder.js +56 -0
  9. package/dist/chat/git.js +27 -0
  10. package/dist/chat/group.js +28 -0
  11. package/dist/chat/health.js +49 -0
  12. package/dist/chat/host.js +123 -0
  13. package/dist/chat/idle.js +20 -0
  14. package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
  15. package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
  16. package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
  17. package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
  18. package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
  19. package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
  20. package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
  21. package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
  22. package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
  23. package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
  24. package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
  25. package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
  26. package/dist/chat/migrations/meta/_journal.json +48 -0
  27. package/dist/chat/names.js +97 -0
  28. package/dist/chat/plan.js +21 -0
  29. package/dist/chat/projects.js +242 -0
  30. package/dist/chat/runner.js +6 -0
  31. package/dist/chat/schema.js +48 -0
  32. package/dist/chat/server.js +202 -0
  33. package/dist/chat/sessions.js +766 -0
  34. package/dist/chat/sweep.js +64 -0
  35. package/dist/chat/transcript.js +31 -0
  36. package/dist/chat/words.js +20 -0
  37. package/dist/chat/workspace.js +134 -0
  38. package/dist/chat/worktree.js +142 -0
  39. package/dist/config/default-path.js +7 -0
  40. package/dist/config/engine-places.js +9 -0
  41. package/dist/config/json-schema.js +15 -0
  42. package/dist/config/load.js +48 -0
  43. package/dist/config/resolve.js +41 -0
  44. package/dist/config/schema.js +225 -0
  45. package/dist/control/epic.js +219 -0
  46. package/dist/control/events.js +48 -0
  47. package/dist/control/files.js +262 -0
  48. package/dist/control/replies.js +1 -0
  49. package/dist/control/server.js +237 -0
  50. package/dist/control/sessions.js +68 -0
  51. package/dist/doctor/doctor.js +255 -0
  52. package/dist/doctor/fixes.js +35 -0
  53. package/dist/execution/agent-runner.js +1 -0
  54. package/dist/execution/caller-result.js +61 -0
  55. package/dist/execution/events.js +19 -0
  56. package/dist/execution/failure.js +3 -0
  57. package/dist/execution/fake.js +82 -0
  58. package/dist/execution/git-workspaces.js +136 -0
  59. package/dist/execution/import-result.js +23 -0
  60. package/dist/execution/workspaces.js +1 -0
  61. package/dist/format/duration.js +12 -0
  62. package/dist/harnesses/agy.js +131 -0
  63. package/dist/harnesses/claude.js +246 -0
  64. package/dist/harnesses/codex.js +118 -0
  65. package/dist/harnesses/common.js +59 -0
  66. package/dist/harnesses/credentials.js +38 -0
  67. package/dist/harnesses/host-runner.js +225 -0
  68. package/dist/harnesses/local.js +25 -0
  69. package/dist/harnesses/names.js +9 -0
  70. package/dist/harnesses/replay-events.js +57 -0
  71. package/dist/integrations/available-repos.js +29 -0
  72. package/dist/integrations/forge-cwd.js +27 -0
  73. package/dist/integrations/github-graphql.js +216 -0
  74. package/dist/integrations/github.js +231 -0
  75. package/dist/logging/logger.js +53 -0
  76. package/dist/logging/redact.js +21 -0
  77. package/dist/orchestration/blockers.js +25 -0
  78. package/dist/orchestration/changes-slices.js +5 -0
  79. package/dist/orchestration/clock.js +7 -0
  80. package/dist/orchestration/engine.js +1068 -0
  81. package/dist/orchestration/fake.js +152 -0
  82. package/dist/orchestration/final-pr.js +15 -0
  83. package/dist/orchestration/github.js +31 -0
  84. package/dist/orchestration/reserve.js +32 -0
  85. package/dist/orchestration/resume-refused.js +7 -0
  86. package/dist/orchestration/retention.js +145 -0
  87. package/dist/orchestration/retry.js +16 -0
  88. package/dist/prompts/build-prompt.js +13 -0
  89. package/dist/prompts/fix.js +21 -0
  90. package/dist/prompts/intro.js +21 -0
  91. package/dist/prompts/invoke.js +2 -0
  92. package/dist/prompts/persona.js +12 -0
  93. package/dist/prompts/result.js +32 -0
  94. package/dist/prompts/review.js +15 -0
  95. package/dist/prompts/ship.js +8 -0
  96. package/dist/status/snapshot.js +92 -0
  97. package/dist/status/types.js +1 -0
  98. package/dist/storage/caller-results.js +7 -0
  99. package/dist/storage/db.js +8 -0
  100. package/dist/storage/epic-review-threads.js +19 -0
  101. package/dist/storage/epic-slices.js +17 -0
  102. package/dist/storage/events.js +52 -0
  103. package/dist/storage/executions.js +31 -0
  104. package/dist/storage/lock.js +21 -0
  105. package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
  106. package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
  107. package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
  108. package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
  109. package/dist/storage/migrations/0004_long_cerise.sql +18 -0
  110. package/dist/storage/migrations/0005_review_result.sql +2 -0
  111. package/dist/storage/migrations/0006_melted_menace.sql +10 -0
  112. package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
  113. package/dist/storage/migrations/0008_panel_state.sql +4 -0
  114. package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
  115. package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
  116. package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
  117. package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
  118. package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
  119. package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
  120. package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
  121. package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
  122. package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
  123. package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
  124. package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
  125. package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
  126. package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
  127. package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
  128. package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
  129. package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
  130. package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
  131. package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
  132. package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
  133. package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
  134. package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
  135. package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
  136. package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
  137. package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
  138. package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
  139. package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
  140. package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
  141. package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
  142. package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
  143. package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
  144. package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
  145. package/dist/storage/migrations/meta/_journal.json +146 -0
  146. package/dist/storage/schema.js +43 -0
  147. package/dist/terminal/cli/args.js +91 -0
  148. package/dist/terminal/cli/cli-error.js +11 -0
  149. package/dist/terminal/cli/commands/chat-host.js +18 -0
  150. package/dist/terminal/cli/commands/chat.js +321 -0
  151. package/dist/terminal/cli/commands/control.js +46 -0
  152. package/dist/terminal/cli/commands/doctor.js +15 -0
  153. package/dist/terminal/cli/commands/init.js +43 -0
  154. package/dist/terminal/cli/commands/restart.js +92 -0
  155. package/dist/terminal/cli/commands/run.js +20 -0
  156. package/dist/terminal/cli/commands/schema.js +4 -0
  157. package/dist/terminal/cli/commands/start.js +21 -0
  158. package/dist/terminal/cli/commands/status.js +61 -0
  159. package/dist/terminal/cli/commands/watch.js +112 -0
  160. package/dist/terminal/cli/compose.js +163 -0
  161. package/dist/terminal/cli/config-file.js +24 -0
  162. package/dist/terminal/cli/control-client.js +94 -0
  163. package/dist/terminal/cli/detached.js +41 -0
  164. package/dist/terminal/cli/interrupt.js +39 -0
  165. package/dist/terminal/cli/main.js +85 -0
  166. package/dist/terminal/cli/open-url.js +9 -0
  167. package/dist/terminal/dashboard/dashboard.js +204 -0
  168. package/dist/terminal/dashboard/history.js +38 -0
  169. package/dist/terminal/dashboard/interactive.js +60 -0
  170. package/dist/terminal/dashboard/keys.js +22 -0
  171. package/dist/terminal/dashboard/logs.js +85 -0
  172. package/dist/terminal/dashboard/panel.js +62 -0
  173. package/dist/terminal/dashboard/plain.js +32 -0
  174. package/dist/terminal/dashboard/printable.js +2 -0
  175. package/dist/terminal/dashboard/ui-state.js +110 -0
  176. package/dist/terminal/prompts/clack.js +121 -0
  177. package/dist/terminal/prompts/types.js +3 -0
  178. package/dist/terminal/wizard/build-config.js +66 -0
  179. package/dist/terminal/wizard/deps.js +94 -0
  180. package/dist/terminal/wizard/flags.js +1 -0
  181. package/dist/terminal/wizard/forge-registry.js +23 -0
  182. package/dist/terminal/wizard/prior.js +20 -0
  183. package/dist/terminal/wizard/questions/agent.js +61 -0
  184. package/dist/terminal/wizard/questions/identities.js +28 -0
  185. package/dist/terminal/wizard/questions/identity.js +30 -0
  186. package/dist/terminal/wizard/questions/options.js +93 -0
  187. package/dist/terminal/wizard/questions/out.js +16 -0
  188. package/dist/terminal/wizard/questions/persona.js +47 -0
  189. package/dist/terminal/wizard/questions/repos.js +61 -0
  190. package/dist/terminal/wizard/summary.js +31 -0
  191. package/dist/terminal/wizard/testing.js +106 -0
  192. package/dist/terminal/wizard/wizard.js +172 -0
  193. package/package.json +46 -3
@@ -0,0 +1,262 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { and, eq, sql } from 'drizzle-orm';
5
+ import { callerResults } from '../storage/caller-results.js';
6
+ import { executions } from '../storage/executions.js';
7
+ import { epics } from '../storage/schema.js';
8
+ import { redact } from '../logging/redact.js';
9
+ import { git } from './epic.js';
10
+ // The reply is bounded: with these it weighs at most about 400 KB, far under the client's 8 MiB line, and says so when it cut.
11
+ export const MAX_FILES_PER_SECTION = 500;
12
+ export const MAX_FILES = 2_000;
13
+ const MAX_SLICES = 100;
14
+ const MAX_PATH = 500;
15
+ const MAX_UNTRACKED_BYTES = 1024 * 1024;
16
+ // a file's diff: past these bytes it is not read, and the rows are cut at the same caps the app draws with
17
+ const MAX_DIFF_BYTES = 4 * 1024 * 1024;
18
+ export const MAX_DIFF_ROWS = 1_000;
19
+ export const MAX_LINE = 2_000;
20
+ /** `git diff --numstat --no-renames -z`: `added<TAB>removed<TAB>path` per record; a binary file counts `-`, taken as 0. */
21
+ function parseNumstat(out) {
22
+ return out.split('\0').flatMap((record) => {
23
+ const [, added, removed, path] = /^(\d+|-)\t(\d+|-)\t(.+)$/s.exec(record) ?? [];
24
+ return path === undefined ? [] : [{ path, added: Number(added) || 0, removed: Number(removed) || 0 }];
25
+ });
26
+ }
27
+ // a driver or a textconv the repository configures never runs: it is the repository's code, and it would skew the numbers
28
+ const SAFE_DIFF = ['--no-color', '--no-ext-diff', '--no-textconv', '--no-renames'];
29
+ const numstat = (card) => {
30
+ const out = git(card.cwd, [...card.guard, 'diff', '--numstat', ...SAFE_DIFF, '-z', ...card.range, '--']);
31
+ return out === null ? null : parseNumstat(out);
32
+ };
33
+ /**
34
+ * `-c` options that keep a worktree's own code from running while it is read: diffing a worktree hashes its edited files through
35
+ * the clean filter (or the long-running `process`) its attributes name, and refreshes through the fsmonitor hook. Every filter the
36
+ * config defines is emptied and not required, so git reads the bytes as they are.
37
+ */
38
+ function guardOf(cwd) {
39
+ const keys = (git(cwd, ['config', '--list', '--name-only']) ?? '').split('\n');
40
+ const filters = new Set(keys.flatMap((key) => (/^filter\.(.+)\.(?:clean|smudge|process|required)$/i.exec(key)?.[1] ?? [])));
41
+ return ['-c', 'core.fsmonitor=false', ...[...filters].flatMap((name) => ['clean=', 'smudge=', 'process=', 'required=false'].flatMap((set) => ['-c', `filter.${name}.${set}`]))];
42
+ }
43
+ /** An untracked file as `files` counts it: its text, or why it has none. A symlink is not followed: it could point anywhere. */
44
+ function readUntracked(cwd, path) {
45
+ try {
46
+ const file = join(cwd, path);
47
+ const stat = lstatSync(file);
48
+ if (stat.isFile() === false)
49
+ return 'binary';
50
+ if (stat.size > MAX_UNTRACKED_BYTES)
51
+ return 'too-big';
52
+ const bytes = readFileSync(file);
53
+ return bytes.includes(0) ? 'binary' : bytes.toString('utf8');
54
+ }
55
+ catch {
56
+ return 'gone'; // between the listing and the read: the agent is working
57
+ }
58
+ }
59
+ const lineCount = (text) => text.split('\n').length - 1 + (text.length > 0 && text.endsWith('\n') === false ? 1 : 0);
60
+ const untrackedPaths = (card) => (git(card.cwd, [...card.guard, 'ls-files', '--others', '--exclude-standard', '-z']) ?? '').split('\0').filter(Boolean).slice(0, MAX_FILES);
61
+ /** New files a lane has not added yet: no diff exists for them, so lines are counted; a binary or big file counts 0. */
62
+ function untracked(card) {
63
+ return untrackedPaths(card).map((path) => {
64
+ const text = readUntracked(card.cwd, path);
65
+ return { path, added: text === 'binary' || text === 'too-big' || text === 'gone' ? 0 : lineCount(text), removed: 0 };
66
+ });
67
+ }
68
+ /** The base the pull request will have: the remote's HEAD, then the usual names; null when the clone has none of them. */
69
+ function defaultBase(cwd) {
70
+ const refs = new Set((git(cwd, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin', 'refs/heads/main', 'refs/heads/master']) ?? '').split('\n'));
71
+ return ['refs/remotes/origin/HEAD', 'refs/remotes/origin/main', 'refs/remotes/origin/master', 'refs/heads/main', 'refs/heads/master'].find((ref) => refs.has(ref)) ?? null;
72
+ }
73
+ /** A folder resolved through symlinks, or null when it is gone. */
74
+ function real(path) {
75
+ try {
76
+ return realpathSync(path);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Whether `list` (`git worktree list --porcelain` of the engine's clone) lists `workspace` (a real path), both sides compared by real path. */
83
+ function listedByGit(list, workspace) {
84
+ return list.split('\n').some((line) => line.startsWith('worktree ') && real(line.slice('worktree '.length)) === workspace);
85
+ }
86
+ /**
87
+ * The lanes of an epic: `git worktree list` entries named `<epic worktree>--lane-<N>` beside it, with a slice branch checked out
88
+ * (a detached one is a free lane and has no work). Nothing outside the epic's own folder is inspected.
89
+ */
90
+ function lanes(list, workspace) {
91
+ const parent = real(dirname(workspace));
92
+ const prefix = `${basename(workspace)}--lane-`;
93
+ return list.split('\n\n').flatMap((block) => {
94
+ const lines = block.split('\n');
95
+ const path = lines.find((l) => l.startsWith('worktree '))?.slice('worktree '.length);
96
+ const number = path === undefined ? '' : basename(path).slice(prefix.length);
97
+ if (path === undefined || basename(path).startsWith(prefix) === false || /^\d{1,2}$/.test(number) === false)
98
+ return [];
99
+ if (lines.some((l) => l.startsWith('branch refs/heads/')) === false || existsSync(path) === false)
100
+ return [];
101
+ return real(dirname(path)) === parent ? [{ lane: Number(number), path }] : [];
102
+ }).sort((a, b) => a.lane - b.lane);
103
+ }
104
+ /**
105
+ * Every card of an epic and where git is asked over which range: a lane's worktree against its fork point, the epic worktree
106
+ * over `<sha>^1..<sha>` of a merged slice, and over the two heads of a fix round. `filesOf` counts over it and `fileDiff` diffs over it,
107
+ * so the two cannot drift apart. null for an unknown epic, 'none' for one whose worktree git does not list for the engine's clone.
108
+ */
109
+ function cardsOf(db, reposDir, repo, issue, executionId) {
110
+ const epic = db.select().from(epics).where(and(eq(epics.repo, repo), eq(epics.issue, issue))).get();
111
+ if (epic === undefined)
112
+ return null;
113
+ const workspace = epic.workspace === null ? null : real(epic.workspace);
114
+ // the folder comes from the database, but it is only read when git lists it for the engine's clone of the repo; asked once per request
115
+ if (workspace === null)
116
+ return 'none';
117
+ const list = git(join(reposDir, repo), ['worktree', 'list', '--porcelain']); // null: no clone yet
118
+ if (list === null || listedByGit(list, workspace) === false)
119
+ return 'none';
120
+ const branch = git(workspace, ['symbolic-ref', '--short', 'HEAD'])?.trim();
121
+ if (branch === undefined || branch === '')
122
+ return 'none';
123
+ const head = `refs/heads/${branch}`;
124
+ const base = defaultBase(workspace);
125
+ const cards = [];
126
+ const runs = db
127
+ .select({ run: executions, result: callerResults.result })
128
+ .from(executions)
129
+ .leftJoin(callerResults, eq(callerResults.executionId, executions.executionId))
130
+ .where(eq(executions.epicId, epic.id))
131
+ .orderBy(sql `executions.rowid`)
132
+ .all();
133
+ const rounds = [];
134
+ let reviewed = null;
135
+ for (const { run, result } of runs) {
136
+ if (result === null || run.stage === 'ship')
137
+ continue;
138
+ const parsed = JSON.parse(result);
139
+ if (run.stage === 'review')
140
+ reviewed = parsed.outcome === 'needs_human' ? null : parsed.pr.headSha;
141
+ else if (parsed.outcome === 'completed' && reviewed !== null && (executionId === undefined || executionId === run.executionId)) {
142
+ rounds.unshift({ id: run.executionId, kind: 'session', title: `Fix, round ${run.round ?? '?'}`, cwd: workspace, range: [reviewed, parsed.pr.headSha], guard: [] });
143
+ }
144
+ }
145
+ if (executionId === undefined) {
146
+ for (const { lane, path } of lanes(list, workspace)) {
147
+ const fork = git(path, ['merge-base', head, 'HEAD'])?.trim();
148
+ if (fork)
149
+ cards.push({ id: `lane-${lane}`, kind: 'lane', title: `Lane ${lane}`, cwd: path, range: [fork], guard: guardOf(path) });
150
+ }
151
+ if (base !== null) {
152
+ const merges = git(workspace, ['log', '--first-parent', '--merges', `--max-count=${MAX_SLICES}`, '--format=%H%x00%s', `${base}..${head}`]) ?? '';
153
+ for (const line of merges.split('\n')) {
154
+ const [sha = '', subject = ''] = line.split('\0');
155
+ // GitHub's own merge subject: `Merge pull request #<pr> from <owner>/<namespace>/<slice number>-<slug>`
156
+ const [, pr, slice] = /^Merge pull request #(\d+) from [^/]+\/.+\/(\d+)-[^/]+$/.exec(subject) ?? [];
157
+ if (slice !== undefined)
158
+ cards.push({ id: `slice-${slice}`, kind: 'merged', title: `Slice #${slice} (PR #${pr})`, cwd: workspace, range: [`${sha}^1`, sha], guard: [] });
159
+ }
160
+ }
161
+ }
162
+ cards.push(...rounds);
163
+ return { workspace, head, base, cards };
164
+ }
165
+ /**
166
+ * The files an epic changed, from git and never from GitHub: the lanes' work so far (against the fork point, uncommitted work included),
167
+ * each merged slice (the first-parent merge commits on the epic branch), each fix round (between the heads of its review and its fix),
168
+ * and the epic branch against the default branch as `total`. An epic without a worktree git lists has nothing to show: empty, not an error.
169
+ * With `executionId`, only that fix round.
170
+ * ponytail: nothing is cached, git is asked on every request (tens of ms); cache a few seconds if the app ever polls faster.
171
+ */
172
+ export function filesOf(db, reposDir, repo, issue, executionId) {
173
+ const found = cardsOf(db, reposDir, repo, issue, executionId);
174
+ if (found === null)
175
+ return null;
176
+ if (found === 'none')
177
+ return { sections: [], total: null, truncated: false };
178
+ const { workspace, head, base } = found;
179
+ const drafts = found.cards.flatMap((card) => {
180
+ const rows = numstat(card);
181
+ return rows === null ? [] : [{ ...card, rows: card.kind === 'lane' ? [...rows, ...untracked(card)] : rows }];
182
+ });
183
+ // a file two lanes touch says so in both
184
+ const laneFiles = new Map(drafts.filter((d) => d.kind === 'lane').map((d) => [d.id, new Set(d.rows.map((r) => r.path))]));
185
+ let left = MAX_FILES;
186
+ let truncated = false;
187
+ const sections = drafts.map(({ id, kind, title, rows }) => {
188
+ const kept = rows.filter((r) => r.path.length <= MAX_PATH).slice(0, Math.min(MAX_FILES_PER_SECTION, left));
189
+ left -= kept.length;
190
+ truncated ||= kept.length < rows.length;
191
+ return {
192
+ id, kind, title,
193
+ added: rows.reduce((sum, r) => sum + r.added, 0),
194
+ removed: rows.reduce((sum, r) => sum + r.removed, 0),
195
+ files: kept.map(({ path, added, removed }) => ({ path, added, removed, alsoIn: kind === 'lane' ? [...laneFiles].filter(([other, set]) => other !== id && set.has(path)).map(([other]) => other) : [] })),
196
+ };
197
+ });
198
+ const all = base === null ? null : numstat({ cwd: workspace, range: [`${base}...${head}`], guard: [] });
199
+ const total = all === null ? null : { files: all.length, added: all.reduce((sum, r) => sum + r.added, 0), removed: all.reduce((sum, r) => sum + r.removed, 0) };
200
+ return { sections, total, truncated };
201
+ }
202
+ /** The diff text of one file over a range; past the byte cap, the whole lines read so far and `cut`. Nothing the repository configures runs: no driver, no textconv. */
203
+ function diffText({ cwd, range, guard }, path) {
204
+ try {
205
+ const text = execFileSync('git', [...guard, 'diff', ...SAFE_DIFF, '-U3', ...range, '--', `:(literal)${path}`], { cwd, encoding: 'utf8', timeout: 5_000, maxBuffer: MAX_DIFF_BYTES, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
206
+ return { text, cut: false };
207
+ }
208
+ catch (error) {
209
+ if (error.code === 'ENOBUFS') {
210
+ const partial = String(error.stdout ?? '');
211
+ return { text: partial.slice(0, partial.lastIndexOf('\n') + 1), cut: true }; // the last line may be half read
212
+ }
213
+ throw new Error('git diff failed in an epic worktree', { cause: error }); // the server logs it and answers `internal error`
214
+ }
215
+ }
216
+ /**
217
+ * The edited fragments of one file of a card: the hunks with three lines of context, an '@' row with git's header before each.
218
+ * null for an unknown epic or card, or a path that is not in the card's own file list: that list is the allowlist, so a pathspec,
219
+ * a `..` or a NUL byte never reaches git. A lane's untracked file is read as `files` counts it, every line a '+'.
220
+ * Redacted as a whole, then cut at MAX_DIFF_ROWS and MAX_LINE.
221
+ */
222
+ export function fileDiff(db, reposDir, repo, issue, section, path) {
223
+ const found = cardsOf(db, reposDir, repo, issue);
224
+ const card = found === null || found === 'none' ? undefined : found.cards.find((c) => c.id === section);
225
+ if (card === undefined || path.length > MAX_PATH)
226
+ return null;
227
+ let out;
228
+ let cut = false;
229
+ if (numstat(card)?.some((r) => r.path === path) === true) {
230
+ ({ text: out, cut } = diffText(card, path));
231
+ }
232
+ else if (card.kind === 'lane' && untrackedPaths(card).includes(path)) {
233
+ const text = readUntracked(card.cwd, path);
234
+ if (text === 'binary' || text === 'too-big')
235
+ return { rows: [], truncated: false, note: text };
236
+ const count = text === 'gone' ? 0 : lineCount(text);
237
+ if (text === 'gone' || count === 0)
238
+ return { rows: [], truncated: false, note: 'no-changes' };
239
+ // the diff git would show once the file is added
240
+ out = `@@ -0,0 +1${count === 1 ? '' : `,${count}`} @@\n` + text.split('\n').slice(0, count).map((line) => `+${line}`).join('\n') + '\n';
241
+ }
242
+ else {
243
+ return null;
244
+ }
245
+ const all = redact(out).split('\n');
246
+ const first = all.findIndex((line) => line.startsWith('@@'));
247
+ if (first < 0)
248
+ return { rows: [], truncated: false, note: /^Binary files /m.test(out) ? 'binary' : 'no-changes' };
249
+ let truncated = cut;
250
+ const rows = all.slice(first).flatMap((line) => {
251
+ const hunk = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
252
+ if (hunk !== null)
253
+ return [{ sign: '@', text: hunk[0] }];
254
+ const sign = line[0];
255
+ if (sign !== '+' && sign !== '-' && sign !== ' ')
256
+ return []; // ``, and the last empty line
257
+ truncated ||= line.length - 1 > MAX_LINE;
258
+ return [{ sign, text: line.slice(1, MAX_LINE + 1) }];
259
+ });
260
+ truncated ||= rows.length > MAX_DIFF_ROWS;
261
+ return { rows: rows.slice(0, MAX_DIFF_ROWS), truncated, note: null };
262
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,237 @@
1
+ import { readFileSync, rmSync } from 'node:fs';
2
+ import { createServer } from 'node:net';
3
+ import { createScopedLogger } from '@ashulab/universal-logger';
4
+ import { z } from 'zod';
5
+ import { controlSocketIn } from '../config/engine-places.js';
6
+ import { optionNamePattern } from '../config/schema.js';
7
+ import { ResumeRefused } from '../orchestration/resume-refused.js';
8
+ const log = createScopedLogger('control');
9
+ const MAX_LINE_BYTES = 16 * 1024;
10
+ const MAX_SOCKET_PATH_BYTES = 103; // macOS: sun_path holds 104 with its terminator
11
+ /** Measured in bytes, never left to Node: 22.22.0 truncates a longer path and binds a mangled name, 24 to 26 throw EINVAL. */
12
+ export function socketPathFits(path) {
13
+ const bytes = Buffer.byteLength(path);
14
+ return { fits: bytes <= MAX_SOCKET_PATH_BYTES, bytes };
15
+ }
16
+ const id = z.number().int().positive();
17
+ const repo = z
18
+ .string()
19
+ .max(200)
20
+ .regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)
21
+ .refine((value) => value.split('/').every((part) => part !== '.' && part !== '..'));
22
+ const issue = z.number().int().positive();
23
+ const optionName = z.string().regex(optionNamePattern);
24
+ // An allowlist: any other command, field or type is rejected before anything acts on it.
25
+ export const request = z.union([
26
+ z.strictObject({ id, cmd: z.enum(['snapshot', 'pause', 'resume', 'checkNow', 'stop']) }),
27
+ z.strictObject({ id, cmd: z.literal('tail'), executionId: z.uuid(), lines: z.number().int().min(1).max(500) }),
28
+ z.strictObject({ id, cmd: z.literal('sessions'), repo, issue }),
29
+ z.strictObject({ id, cmd: z.literal('events'), executionId: z.uuid(), after: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(200) }),
30
+ z.strictObject({ id, cmd: z.literal('epic'), repo, issue }),
31
+ z.strictObject({ id, cmd: z.literal('files'), repo, issue, executionId: z.uuid().optional() }),
32
+ z.strictObject({ id, cmd: z.literal('fileDiff'), repo, issue, section: z.string().min(1).max(100), path: z.string().min(1).max(500) }),
33
+ z.strictObject({ id, cmd: z.literal('availableRepos'), page: z.int().min(1).max(100).optional() }),
34
+ z.strictObject({ id, cmd: z.literal('resumeEpic'), repo, issue, author: optionName.optional(), reviewer: optionName.optional() }),
35
+ z.strictObject({ id, cmd: z.literal('chooseOptions'), repo, issue, author: optionName.optional(), reviewer: optionName.optional() }),
36
+ ]);
37
+ // src/control and dist/control both sit two folders under the package root.
38
+ export const version = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
39
+ /** pause, resume, checkNow and tail. Like the panel's keys, a check is started and not waited for: its failure is the engine's to report. */
40
+ function control(command, controls) {
41
+ try {
42
+ if (command.cmd === 'tail')
43
+ return { id: command.id, ok: true, data: controls.logs(command.executionId, command.lines) };
44
+ if (command.cmd === 'checkNow')
45
+ void Promise.resolve(controls.poll()).catch((error) => log.error('check now failed', error));
46
+ else
47
+ void Promise.resolve(controls.setPaused(command.cmd === 'pause')).catch((error) => log.error(`${command.cmd} failed`, error));
48
+ return { id: command.id, ok: true };
49
+ }
50
+ catch (error) {
51
+ log.error(`${command.cmd} failed`, error);
52
+ return { id: command.id, ok: false, error: 'internal error' };
53
+ }
54
+ }
55
+ /** The reply to one line; a promise for the requests that wait on GitHub. */
56
+ function answer(line, snapshot, controls, stopAfterReply) {
57
+ let value;
58
+ try {
59
+ value = JSON.parse(line);
60
+ }
61
+ catch {
62
+ return { id: null, ok: false, error: 'invalid JSON' };
63
+ }
64
+ const parsed = request.safeParse(value);
65
+ if (parsed.success === false) {
66
+ // the id is echoed only when it is a valid one, so the client can match the error
67
+ const given = typeof value === 'object' && value !== null ? id.safeParse(value.id) : null;
68
+ return { id: given?.success ? given.data : null, ok: false, error: 'invalid request' };
69
+ }
70
+ if (parsed.data.cmd === 'snapshot') {
71
+ try {
72
+ return { id: parsed.data.id, ok: true, data: snapshot() };
73
+ }
74
+ catch (error) {
75
+ log.error('snapshot failed', error);
76
+ return { id: parsed.data.id, ok: false, error: 'internal error' };
77
+ }
78
+ }
79
+ if (parsed.data.cmd === 'sessions' || parsed.data.cmd === 'epic') {
80
+ const { id: replyId, cmd, repo, issue } = parsed.data;
81
+ try {
82
+ const reply = controls[cmd](repo, issue);
83
+ return reply === null ? { id: replyId, ok: false, error: 'unknown epic' } : { id: replyId, ok: true, data: reply };
84
+ }
85
+ catch (error) {
86
+ log.error(`${cmd} failed`, error);
87
+ return { id: replyId, ok: false, error: 'internal error' };
88
+ }
89
+ }
90
+ if (parsed.data.cmd === 'files') {
91
+ const { id: replyId, repo, issue, executionId } = parsed.data;
92
+ try {
93
+ const reply = controls.files(repo, issue, executionId);
94
+ return reply === null ? { id: replyId, ok: false, error: 'unknown epic' } : { id: replyId, ok: true, data: reply };
95
+ }
96
+ catch (error) {
97
+ log.error('files failed', error);
98
+ return { id: replyId, ok: false, error: 'internal error' };
99
+ }
100
+ }
101
+ if (parsed.data.cmd === 'fileDiff') {
102
+ const { id: replyId, repo, issue, section, path } = parsed.data;
103
+ try {
104
+ const reply = controls.fileDiff(repo, issue, section, path);
105
+ // never git's own message: it can name more of the machine than the client should see
106
+ return reply === null ? { id: replyId, ok: false, error: 'unknown file' } : { id: replyId, ok: true, data: reply };
107
+ }
108
+ catch (error) {
109
+ log.error('fileDiff failed', error);
110
+ return { id: replyId, ok: false, error: 'internal error' };
111
+ }
112
+ }
113
+ if (parsed.data.cmd === 'availableRepos') {
114
+ const { id: replyId, page } = parsed.data;
115
+ // never the reason in the reply: gh's own message can carry more than the client should see
116
+ return Promise.resolve()
117
+ .then(() => controls.availableRepos(page ?? 1))
118
+ .then((data) => ({ id: replyId, ok: true, data }), (error) => {
119
+ log.error('availableRepos failed', error);
120
+ return { id: replyId, ok: false, error: 'github unavailable' };
121
+ });
122
+ }
123
+ if (parsed.data.cmd === 'resumeEpic') {
124
+ const { id: replyId, repo, issue, author, reviewer } = parsed.data;
125
+ return Promise.resolve()
126
+ .then(() => controls.resumeEpic(repo, issue, { author, reviewer }))
127
+ .then(() => ({ id: replyId, ok: true }), (error) => {
128
+ if (error instanceof ResumeRefused)
129
+ return { id: replyId, ok: false, error: error.reason };
130
+ log.error('resumeEpic failed', error);
131
+ return { id: replyId, ok: false, error: 'internal error' };
132
+ });
133
+ }
134
+ if (parsed.data.cmd === 'chooseOptions') {
135
+ const { id: replyId, repo, issue, author, reviewer } = parsed.data;
136
+ return Promise.resolve()
137
+ .then(() => controls.chooseOptions(repo, issue, { author, reviewer }))
138
+ .then(() => ({ id: replyId, ok: true }), (error) => {
139
+ if (error instanceof ResumeRefused)
140
+ return { id: replyId, ok: false, error: error.reason };
141
+ log.error('chooseOptions failed', error);
142
+ return { id: replyId, ok: false, error: 'internal error' };
143
+ });
144
+ }
145
+ if (parsed.data.cmd === 'events') {
146
+ const { id: replyId, executionId, after, limit } = parsed.data;
147
+ try {
148
+ const reply = controls.events(executionId, after ?? 0, limit);
149
+ return reply === null ? { id: replyId, ok: false, error: 'unknown execution' } : { id: replyId, ok: true, data: reply };
150
+ }
151
+ catch (error) {
152
+ log.error('events failed', error);
153
+ return { id: replyId, ok: false, error: 'internal error' };
154
+ }
155
+ }
156
+ if (parsed.data.cmd !== 'stop')
157
+ return control(parsed.data, controls);
158
+ stopAfterReply();
159
+ return { id: parsed.data.id, ok: true };
160
+ }
161
+ /**
162
+ * Opens `control.sock` in the engine's state folder: newline-delimited JSON, a hello on connect and one reply per request. The caller
163
+ * must hold the instance lock: that is what makes any socket file already there a leftover of a crash, safe to replace.
164
+ * A path that does not fit, or a socket that cannot open, is logged and Carrier goes on without it. `close()` is synchronous and removes the file.
165
+ */
166
+ export async function startControlServer({ stateDir, snapshot, startedAt, controls, stop }) {
167
+ const path = controlSocketIn(stateDir);
168
+ const { fits, bytes } = socketPathFits(path);
169
+ if (fits === false) {
170
+ log.warn(`control socket not opened: ${path} is ${bytes} bytes, the limit is ${MAX_SOCKET_PATH_BYTES}; use a shorter Carrier folder`);
171
+ return { close: () => { } };
172
+ }
173
+ const sockets = new Set();
174
+ const hello = { event: 'hello', protocol: 1, pid: process.pid, startedAt, version, capabilities: ['sessions', 'events', 'epic', 'files', 'fileDiff', 'availableRepos', 'availableReposPaged', 'resumeEpic', 'agentOptions'] };
175
+ const send = (socket, message, written) => void socket.write(`${JSON.stringify(message)}\n`, written);
176
+ const server = createServer((socket) => {
177
+ sockets.add(socket);
178
+ socket.setEncoding('utf8');
179
+ // a client that goes away mid-write is routine, not a failure
180
+ socket.on('error', (error) => log.debug('control connection error', error));
181
+ socket.on('close', () => sockets.delete(socket));
182
+ send(socket, hello);
183
+ let pending = '';
184
+ // replies leave in the order the requests came, also when one of them waits on GitHub
185
+ let replies = Promise.resolve();
186
+ socket.on('data', (chunk) => {
187
+ pending += chunk;
188
+ for (let end = pending.indexOf('\n'); end >= 0; end = pending.indexOf('\n')) {
189
+ const line = pending.slice(0, end);
190
+ pending = pending.slice(end + 1);
191
+ if (Buffer.byteLength(line) > MAX_LINE_BYTES)
192
+ return tooLong(socket);
193
+ // `stop`: the reply is flushed first, then the shared shutdown runs and ends with the connection closing
194
+ let afterReply;
195
+ const stopAfterReply = () => void (afterReply = () => void stop().catch((error) => log.error('stop failed', error)));
196
+ const reply = answer(line, snapshot, controls, stopAfterReply);
197
+ replies = replies.then(async () => {
198
+ const message = await reply;
199
+ if (socket.destroyed === false)
200
+ send(socket, message, afterReply);
201
+ });
202
+ }
203
+ if (Buffer.byteLength(pending) > MAX_LINE_BYTES)
204
+ tooLong(socket);
205
+ });
206
+ });
207
+ const tooLong = (socket) => {
208
+ socket.removeAllListeners('data'); // nothing more is read from this client
209
+ socket.end(`${JSON.stringify({ id: null, ok: false, error: 'line too long' })}\n`);
210
+ };
211
+ server.on('error', (error) => log.error('control socket error', error));
212
+ const close = () => {
213
+ server.close();
214
+ for (const socket of sockets)
215
+ socket.destroy();
216
+ rmSync(path, { force: true });
217
+ };
218
+ try {
219
+ rmSync(path, { force: true });
220
+ await new Promise((resolve, reject) => {
221
+ server.once('error', reject);
222
+ // bind is synchronous: with this umask the file is born 0600, and the umask is back before anything else runs
223
+ const umask = process.umask(0o177);
224
+ try {
225
+ server.listen(path, resolve);
226
+ }
227
+ finally {
228
+ process.umask(umask);
229
+ }
230
+ });
231
+ }
232
+ catch (error) {
233
+ log.error('control socket not opened', error);
234
+ return { close: () => { } };
235
+ }
236
+ return { close };
237
+ }
@@ -0,0 +1,68 @@
1
+ import { and, desc, eq, sql } from 'drizzle-orm';
2
+ import { executions } from '../storage/executions.js';
3
+ import { epics } from '../storage/schema.js';
4
+ import { configDirVariable } from '../harnesses/names.js';
5
+ import { redact } from '../logging/redact.js';
6
+ import { callerResults } from '../storage/caller-results.js';
7
+ const MAX_SUMMARY = 1500;
8
+ const MAX_EVIDENCE = 600;
9
+ const MAX_REASON = 600;
10
+ const MAX_FAILURE_DETAIL = 300;
11
+ /** Text an agent wrote: redacted first, so a token is never cut into something the patterns no longer match, then cut. */
12
+ const safe = (text, max) => redact(text).slice(0, max);
13
+ function resultOf(raw) {
14
+ if (raw === null)
15
+ return null;
16
+ const parsed = JSON.parse(raw);
17
+ return {
18
+ outcome: parsed.outcome,
19
+ summary: safe(parsed.summary, MAX_SUMMARY),
20
+ verification: parsed.outcome === 'completed' ? { ci: parsed.verification.ci, evidence: safe(parsed.verification.evidence, MAX_EVIDENCE) } : null,
21
+ reason: parsed.outcome === 'needs_human' ? safe(parsed.reason, MAX_REASON) : null,
22
+ };
23
+ }
24
+ function failureOf(row) {
25
+ if (row.failureKind === null)
26
+ return null;
27
+ const { failureKind: kind, failureDetail, exitCode, signal, hostFreeMemMb, hostLoad } = row;
28
+ return { kind, detail: safe(failureDetail ?? '', MAX_FAILURE_DETAIL), exitCode, signal, hostFreeMemMb, hostLoad };
29
+ }
30
+ /** The folders the environment really sets, as the agents inherit them: a harness whose variable is unset has no entry, not its default folder. */
31
+ export const inheritedConfigDirs = (env = process.env) => Object.fromEntries(Object.entries(configDirVariable).flatMap(([harness, variable]) => (env[variable] ? [[harness, env[variable]]] : [])));
32
+ /** The agent an execution ran with: its own row, or the epic's frozen configuration for a row written before it was recorded. */
33
+ export function agentOf(row, config) {
34
+ const frozen = config?.agents[row.stage === 'review' ? 'reviewer' : 'author'];
35
+ return {
36
+ harness: (row.harness ?? frozen?.harness ?? 'claude'),
37
+ model: row.model ?? frozen?.model ?? '',
38
+ provider: row.harness === null ? (frozen?.provider ?? null) : row.provider,
39
+ };
40
+ }
41
+ /**
42
+ * Every execution of an epic, newest first; null for an epic the engine does not know.
43
+ * A session has a descriptor only once it is not running and its id and folder are known (ADR 0007); `configDir` is the folder the execution that wrote it ran with: its option's, else the engine environment's (`inherited`).
44
+ */
45
+ export function sessionsOf(db, repo, issue, inherited = {}) {
46
+ const epic = db.select({ id: epics.id, config: epics.config }).from(epics).where(and(eq(epics.repo, repo), eq(epics.issue, issue))).get();
47
+ if (epic === undefined)
48
+ return null;
49
+ const rows = db
50
+ .select({ row: executions, result: callerResults.result })
51
+ .from(executions)
52
+ .leftJoin(callerResults, eq(callerResults.executionId, executions.executionId))
53
+ .where(eq(executions.epicId, epic.id))
54
+ .orderBy(desc(executions.startedAt), sql `executions.rowid desc`)
55
+ .all();
56
+ return {
57
+ sessions: rows.map(({ row, result }) => {
58
+ const role = row.stage === 'review' ? 'reviewer' : 'author';
59
+ const agent = agentOf(row, epic.config);
60
+ const harness = agent.harness;
61
+ const configDir = row.configDir ?? inherited[harness];
62
+ const resume = row.status === 'running' || row.sessionId === null || row.workspace === null
63
+ ? null
64
+ : { harness, sessionId: row.sessionId, cwd: row.workspace, ...(configDir !== undefined && { configDir }) };
65
+ return { executionId: row.executionId, stage: row.stage, role, round: row.round, agent, status: row.status, startedAt: row.startedAt, finishedAt: row.finishedAt, resume, result: resultOf(result), failure: failureOf(row) };
66
+ }),
67
+ };
68
+ }