@naxodev/apnea 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +61 -0
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/SECURITY.md +35 -0
- package/briefs/coder.md +40 -0
- package/briefs/orchestrator.md +49 -0
- package/briefs/planner.md +54 -0
- package/briefs/reviewer.md +40 -0
- package/dist/cli.js +39397 -0
- package/docs/adr/0001-completion-signaling.md +3 -0
- package/docs/adr/0002-orchestrator-authority.md +3 -0
- package/docs/adr/0003-verify-at-gate.md +3 -0
- package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
- package/docs/adr/0005-harness-profiles.md +5 -0
- package/docs/adr/0006-config-trust-model.md +3 -0
- package/docs/adr/0007-jj-first-commits.md +3 -0
- package/docs/adr/0008-effect-v4-internals.md +3 -0
- package/docs/adr/0009-cli-driver-split.md +9 -0
- package/docs/adr/0010-package-split.md +23 -0
- package/docs/protocol/artifacts.md +68 -0
- package/docs/protocol/config.md +186 -0
- package/docs/protocol/manual-gate.md +38 -0
- package/docs/protocol/overview.md +96 -0
- package/extension/adapters/commit.ts +15 -0
- package/extension/adapters/dispatch.ts +15 -0
- package/extension/adapters/setup.ts +34 -0
- package/extension/adapters/start.ts +16 -0
- package/extension/adapters/status.ts +24 -0
- package/extension/adapters/wait.ts +20 -0
- package/extension/api.ts +16 -0
- package/extension/cli/format.ts +44 -0
- package/extension/cli/human-gate.ts +44 -0
- package/extension/cli/main.ts +218 -0
- package/extension/cli/parse.ts +48 -0
- package/extension/domain/artifact-kind.ts +26 -0
- package/extension/domain/frontmatter.ts +69 -0
- package/extension/domain/herdr.ts +109 -0
- package/extension/domain/paths.ts +139 -0
- package/extension/domain/recovery.ts +25 -0
- package/extension/domain/rounds.ts +16 -0
- package/extension/domain/setup.ts +158 -0
- package/extension/domain/slug.ts +9 -0
- package/extension/domain/state-machine.ts +132 -0
- package/extension/domain/timeouts.ts +24 -0
- package/extension/domain/types.ts +145 -0
- package/extension/domain/verify-commands.ts +128 -0
- package/extension/errors.ts +247 -0
- package/extension/host-adapter.ts +8 -0
- package/extension/registry.ts +323 -0
- package/extension/result.ts +55 -0
- package/extension/run-tool.ts +43 -0
- package/extension/schema/config.ts +315 -0
- package/extension/schema/frontmatter.ts +34 -0
- package/extension/schema/state.ts +119 -0
- package/extension/services/app-live.ts +24 -0
- package/extension/services/config.ts +103 -0
- package/extension/services/file-system.ts +178 -0
- package/extension/services/herdr.ts +860 -0
- package/extension/services/run-store.ts +99 -0
- package/extension/services/vcs.ts +246 -0
- package/extension/workflows/commit.ts +148 -0
- package/extension/workflows/dispatch.ts +693 -0
- package/extension/workflows/reset.ts +26 -0
- package/extension/workflows/setup.ts +301 -0
- package/extension/workflows/start.ts +149 -0
- package/extension/workflows/status.ts +45 -0
- package/extension/workflows/wait.ts +793 -0
- package/herdr-plugin/herdr-plugin.toml +15 -0
- package/herdr-plugin/scripts/run-task.sh +8 -0
- package/package.json +75 -0
- package/schemas/artifact-frontmatter.md +38 -0
- package/schemas/config.schema.json +50 -0
- package/schemas/state.schema.json +63 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as path from "node:path"
|
|
2
|
+
import { Clock, Context, Effect, Layer, Result } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
apneaRoot,
|
|
5
|
+
artifactsDir,
|
|
6
|
+
statePath,
|
|
7
|
+
tasksDir,
|
|
8
|
+
} from "../domain/paths.ts"
|
|
9
|
+
import { NoRunState, StateCorrupt } from "../errors.ts"
|
|
10
|
+
import type { RunState } from "../domain/types.ts"
|
|
11
|
+
import { decodeRunState } from "../schema/state.ts"
|
|
12
|
+
import { FileSystem, type FileSystemService } from "./file-system.ts"
|
|
13
|
+
|
|
14
|
+
export interface RunStoreService {
|
|
15
|
+
readonly load: (root: string) => Effect.Effect<RunState | null, StateCorrupt>
|
|
16
|
+
readonly save: (state: RunState, root: string) => Effect.Effect<void>
|
|
17
|
+
readonly require: (
|
|
18
|
+
root: string,
|
|
19
|
+
) => Effect.Effect<RunState, NoRunState | StateCorrupt>
|
|
20
|
+
readonly abandon: (root: string) => Effect.Effect<string, NoRunState>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class RunStore extends Context.Service<RunStore, RunStoreService>()(
|
|
24
|
+
"apnea/RunStore",
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
function ensureApneaDirs(
|
|
28
|
+
fs: FileSystemService,
|
|
29
|
+
root: string,
|
|
30
|
+
): Effect.Effect<void> {
|
|
31
|
+
const dirs = [
|
|
32
|
+
apneaRoot(root),
|
|
33
|
+
artifactsDir(root),
|
|
34
|
+
tasksDir(root),
|
|
35
|
+
path.join(artifactsDir(root), "plan-review"),
|
|
36
|
+
]
|
|
37
|
+
return Effect.forEach(dirs, (d) => fs.mkdir(d, { recursive: true }), {
|
|
38
|
+
discard: true,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const RunStoreLive = Layer.effect(
|
|
43
|
+
RunStore,
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const fs = yield* FileSystem
|
|
46
|
+
|
|
47
|
+
const load = (root: string): Effect.Effect<RunState | null, StateCorrupt> =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const p = statePath(root)
|
|
50
|
+
const present = yield* fs.exists(p)
|
|
51
|
+
if (!present) return null
|
|
52
|
+
const text = yield* fs.readFile(p)
|
|
53
|
+
let json: unknown
|
|
54
|
+
try {
|
|
55
|
+
json = JSON.parse(text)
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return yield* new StateCorrupt({
|
|
58
|
+
path: p,
|
|
59
|
+
message: e instanceof Error ? e.message : String(e),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
const decoded = decodeRunState(json, p)
|
|
63
|
+
if (Result.isFailure(decoded)) {
|
|
64
|
+
return yield* decoded.failure
|
|
65
|
+
}
|
|
66
|
+
return decoded.success
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const save = (state: RunState, root: string): Effect.Effect<void> =>
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
yield* ensureApneaDirs(fs, root)
|
|
72
|
+
const p = statePath(root)
|
|
73
|
+
const body = `${JSON.stringify(state, null, 2)}\n`
|
|
74
|
+
yield* fs.writeFile(p, body)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const require = (
|
|
78
|
+
root: string,
|
|
79
|
+
): Effect.Effect<RunState, NoRunState | StateCorrupt> =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const s = yield* load(root)
|
|
82
|
+
if (!s) return yield* new NoRunState({})
|
|
83
|
+
return s
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const abandon = (root: string): Effect.Effect<string, NoRunState> =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const p = statePath(root)
|
|
89
|
+
const present = yield* fs.exists(p)
|
|
90
|
+
if (!present) return yield* new NoRunState({})
|
|
91
|
+
const millis = yield* Clock.currentTimeMillis
|
|
92
|
+
const bak = `${p}.abandoned.${millis}`
|
|
93
|
+
yield* fs.rename(p, bak)
|
|
94
|
+
return bak
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return RunStore.of({ load, save, require, abandon })
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import { Context, Effect, Layer } from "effect"
|
|
4
|
+
import { VcsError } from "../errors.ts"
|
|
5
|
+
import type { VcsBackend } from "../domain/types.ts"
|
|
6
|
+
import { FileSystem } from "./file-system.ts"
|
|
7
|
+
|
|
8
|
+
export interface VcsService {
|
|
9
|
+
readonly detect: (root: string) => Effect.Effect<VcsBackend | null>
|
|
10
|
+
readonly isDirty: (root: string, vcs: VcsBackend) => Effect.Effect<boolean>
|
|
11
|
+
readonly treeFingerprint: (
|
|
12
|
+
root: string,
|
|
13
|
+
vcs: VcsBackend,
|
|
14
|
+
) => Effect.Effect<string>
|
|
15
|
+
readonly ensureGitBranch: (
|
|
16
|
+
root: string,
|
|
17
|
+
slug: string,
|
|
18
|
+
) => Effect.Effect<string, VcsError>
|
|
19
|
+
readonly commitPhase: (
|
|
20
|
+
root: string,
|
|
21
|
+
vcs: VcsBackend,
|
|
22
|
+
message: string,
|
|
23
|
+
) => Effect.Effect<string, VcsError>
|
|
24
|
+
readonly setBookmarkAtTerminus: (
|
|
25
|
+
root: string,
|
|
26
|
+
slug: string,
|
|
27
|
+
) => Effect.Effect<void>
|
|
28
|
+
readonly runVerify: (
|
|
29
|
+
root: string,
|
|
30
|
+
commands: readonly string[],
|
|
31
|
+
timeoutMs: number,
|
|
32
|
+
) => Effect.Effect<{ ok: boolean; log: string }>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Vcs extends Context.Service<Vcs, VcsService>()("apnea/Vcs") {}
|
|
36
|
+
|
|
37
|
+
function run(
|
|
38
|
+
cmd: string,
|
|
39
|
+
args: string[],
|
|
40
|
+
cwd: string,
|
|
41
|
+
): { ok: boolean; stdout: string; stderr: string; code: number } {
|
|
42
|
+
const r = spawnSync(cmd, args, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
46
|
+
})
|
|
47
|
+
return {
|
|
48
|
+
ok: r.status === 0,
|
|
49
|
+
stdout: (r.stdout ?? "").toString(),
|
|
50
|
+
stderr: (r.stderr ?? "").toString(),
|
|
51
|
+
code: r.status ?? 1,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drop .apnea/ runtime paths from VCS summaries (artifacts are allowed). */
|
|
56
|
+
export function filterAppPaths(summary: string): string {
|
|
57
|
+
return summary
|
|
58
|
+
.split(/\r?\n/)
|
|
59
|
+
.filter((line) => {
|
|
60
|
+
const t = line.trim()
|
|
61
|
+
if (!t) return false
|
|
62
|
+
// git porcelain: XY path
|
|
63
|
+
if (/^.. /.test(line)) {
|
|
64
|
+
const p = line.slice(3).replace(/^"|"$/g, "")
|
|
65
|
+
return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
|
|
66
|
+
}
|
|
67
|
+
// jj summary often: M path / A path
|
|
68
|
+
const m = t.match(/^[A-Z]+\s+(.+)$/)
|
|
69
|
+
if (m) {
|
|
70
|
+
const p = m[1]!
|
|
71
|
+
return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
|
|
72
|
+
}
|
|
73
|
+
return !t.includes(".apnea/")
|
|
74
|
+
})
|
|
75
|
+
.join("\n")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const VcsLive = Layer.effect(
|
|
79
|
+
Vcs,
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const fs = yield* FileSystem
|
|
82
|
+
|
|
83
|
+
const detect = (root: string): Effect.Effect<VcsBackend | null> =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
if (yield* fs.exists(path.join(root, ".jj"))) return "jj"
|
|
86
|
+
if (yield* fs.exists(path.join(root, ".git"))) return "git"
|
|
87
|
+
return null
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const treeFingerprint = (
|
|
91
|
+
root: string,
|
|
92
|
+
vcs: VcsBackend,
|
|
93
|
+
): Effect.Effect<string> =>
|
|
94
|
+
Effect.sync(() => {
|
|
95
|
+
if (vcs === "jj") {
|
|
96
|
+
const r = run("jj", ["diff", "--summary"], root)
|
|
97
|
+
return filterAppPaths(r.stdout)
|
|
98
|
+
}
|
|
99
|
+
const r = run("git", ["status", "--porcelain"], root)
|
|
100
|
+
return filterAppPaths(r.stdout)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const isDirty = (root: string, vcs: VcsBackend): Effect.Effect<boolean> =>
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const fp = yield* treeFingerprint(root, vcs)
|
|
106
|
+
return fp.trim().length > 0
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const ensureGitBranch = (
|
|
110
|
+
root: string,
|
|
111
|
+
slug: string,
|
|
112
|
+
): Effect.Effect<string, VcsError> =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const branch = `apnea/${slug}`
|
|
115
|
+
const cur = yield* Effect.sync(() =>
|
|
116
|
+
run("git", ["rev-parse", "--abbrev-ref", "HEAD"], root),
|
|
117
|
+
)
|
|
118
|
+
if (cur.stdout.trim() === branch) return branch
|
|
119
|
+
const exists = yield* Effect.sync(() =>
|
|
120
|
+
run(
|
|
121
|
+
"git",
|
|
122
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
123
|
+
root,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
if (exists.ok) {
|
|
127
|
+
const co = yield* Effect.sync(() =>
|
|
128
|
+
run("git", ["checkout", branch], root),
|
|
129
|
+
)
|
|
130
|
+
if (!co.ok) {
|
|
131
|
+
return yield* new VcsError({
|
|
132
|
+
message: `git checkout ${branch}: ${co.stderr}`,
|
|
133
|
+
command: `git checkout ${branch}`,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
return branch
|
|
137
|
+
}
|
|
138
|
+
const cr = yield* Effect.sync(() =>
|
|
139
|
+
run("git", ["checkout", "-b", branch], root),
|
|
140
|
+
)
|
|
141
|
+
if (!cr.ok) {
|
|
142
|
+
return yield* new VcsError({
|
|
143
|
+
message: `git checkout -b ${branch}: ${cr.stderr}`,
|
|
144
|
+
command: `git checkout -b ${branch}`,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
return branch
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const commitPhase = (
|
|
151
|
+
root: string,
|
|
152
|
+
vcs: VcsBackend,
|
|
153
|
+
message: string,
|
|
154
|
+
): Effect.Effect<string, VcsError> =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
if (vcs === "jj") {
|
|
157
|
+
const d = yield* Effect.sync(() =>
|
|
158
|
+
run("jj", ["describe", "-m", message], root),
|
|
159
|
+
)
|
|
160
|
+
if (!d.ok) {
|
|
161
|
+
return yield* new VcsError({
|
|
162
|
+
message: d.stderr || d.stdout,
|
|
163
|
+
command: "jj describe",
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
const n = yield* Effect.sync(() => run("jj", ["new"], root))
|
|
167
|
+
if (!n.ok) {
|
|
168
|
+
return yield* new VcsError({
|
|
169
|
+
message: n.stderr || n.stdout,
|
|
170
|
+
command: "jj new",
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
return "jj describe + new"
|
|
174
|
+
}
|
|
175
|
+
const add = yield* Effect.sync(() => run("git", ["add", "-A"], root))
|
|
176
|
+
if (!add.ok) {
|
|
177
|
+
return yield* new VcsError({
|
|
178
|
+
message: add.stderr,
|
|
179
|
+
command: "git add -A",
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
const c = yield* Effect.sync(() =>
|
|
183
|
+
run("git", ["commit", "-m", message], root),
|
|
184
|
+
)
|
|
185
|
+
if (!c.ok) {
|
|
186
|
+
return yield* new VcsError({
|
|
187
|
+
message: c.stderr || c.stdout,
|
|
188
|
+
command: "git commit",
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return "git commit"
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const setBookmarkAtTerminus = (
|
|
195
|
+
root: string,
|
|
196
|
+
slug: string,
|
|
197
|
+
): Effect.Effect<void> =>
|
|
198
|
+
Effect.sync(() => {
|
|
199
|
+
const name = `apnea/${slug}`
|
|
200
|
+
const r = run("jj", ["bookmark", "set", name, "-r", "@-"], root)
|
|
201
|
+
if (!r.ok) {
|
|
202
|
+
run("jj", ["bookmark", "create", name, "-r", "@-"], root)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const runVerify = (
|
|
207
|
+
root: string,
|
|
208
|
+
commands: readonly string[],
|
|
209
|
+
timeoutMs: number,
|
|
210
|
+
): Effect.Effect<{ ok: boolean; log: string }> =>
|
|
211
|
+
Effect.sync(() => {
|
|
212
|
+
const lines: string[] = []
|
|
213
|
+
for (const cmd of commands) {
|
|
214
|
+
lines.push(`$ ${cmd}`)
|
|
215
|
+
const r = spawnSync("bash", ["-lc", cmd], {
|
|
216
|
+
cwd: root,
|
|
217
|
+
encoding: "utf8",
|
|
218
|
+
timeout: timeoutMs,
|
|
219
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
220
|
+
})
|
|
221
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trimEnd()
|
|
222
|
+
if (out) lines.push(out)
|
|
223
|
+
lines.push(`exit=${r.status ?? 1}`)
|
|
224
|
+
if (r.error) {
|
|
225
|
+
lines.push(String(r.error))
|
|
226
|
+
return { ok: false, log: lines.join("\n") }
|
|
227
|
+
}
|
|
228
|
+
if (r.status !== 0) {
|
|
229
|
+
return { ok: false, log: lines.join("\n") }
|
|
230
|
+
}
|
|
231
|
+
lines.push("")
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, log: lines.join("\n") }
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return Vcs.of({
|
|
237
|
+
detect,
|
|
238
|
+
isDirty,
|
|
239
|
+
treeFingerprint,
|
|
240
|
+
ensureGitBranch,
|
|
241
|
+
commitPhase,
|
|
242
|
+
setBookmarkAtTerminus,
|
|
243
|
+
runVerify,
|
|
244
|
+
})
|
|
245
|
+
}),
|
|
246
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as path from "node:path"
|
|
2
|
+
import { Effect, Result } from "effect"
|
|
3
|
+
import { asVerdict, parseFrontMatter } from "../domain/frontmatter.ts"
|
|
4
|
+
import { abs, phaseDir, rel } from "../domain/paths.ts"
|
|
5
|
+
import { nextAfter, toolAllowed } from "../domain/state-machine.ts"
|
|
6
|
+
import { extractVerifyCommands } from "../domain/verify-commands.ts"
|
|
7
|
+
import {
|
|
8
|
+
ArtifactInvalid,
|
|
9
|
+
GateRefused,
|
|
10
|
+
VerifyFailed,
|
|
11
|
+
type AppError,
|
|
12
|
+
} from "../errors.ts"
|
|
13
|
+
import { ok, type ToolResult } from "../result.ts"
|
|
14
|
+
import { Config } from "../services/config.ts"
|
|
15
|
+
import { FileSystem } from "../services/file-system.ts"
|
|
16
|
+
import { RunStore } from "../services/run-store.ts"
|
|
17
|
+
import { Vcs } from "../services/vcs.ts"
|
|
18
|
+
|
|
19
|
+
export type CommitParams = {
|
|
20
|
+
message?: string
|
|
21
|
+
/** When true and no more phases, advance to finishing instead of phase_packaging */
|
|
22
|
+
no_remaining_phases?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Require APPROVED code review, run phase package verify commands,
|
|
27
|
+
* jj/git commit, advance phase. Refusals are tagged failures only.
|
|
28
|
+
*/
|
|
29
|
+
export const commitWorkflow = (
|
|
30
|
+
params: CommitParams,
|
|
31
|
+
root: string,
|
|
32
|
+
): Effect.Effect<ToolResult, AppError, FileSystem | RunStore | Config | Vcs> =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const store = yield* RunStore
|
|
35
|
+
const fs = yield* FileSystem
|
|
36
|
+
const config = yield* Config
|
|
37
|
+
const vcs = yield* Vcs
|
|
38
|
+
|
|
39
|
+
const state = yield* store.require(root)
|
|
40
|
+
|
|
41
|
+
const allowed = toolAllowed(state.step, "workflow_commit_phase")
|
|
42
|
+
if (Result.isFailure(allowed)) {
|
|
43
|
+
return yield* allowed.failure
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const reviewRel = state.current_code_review
|
|
47
|
+
if (!reviewRel) {
|
|
48
|
+
return yield* new GateRefused({
|
|
49
|
+
gate: "commit",
|
|
50
|
+
message: "current_code_review not set — complete code_review first",
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const reviewAbs = abs(reviewRel, root)
|
|
55
|
+
const reviewPresent = yield* fs.exists(reviewAbs)
|
|
56
|
+
let fm: ReturnType<typeof parseFrontMatter> = null
|
|
57
|
+
if (reviewPresent) {
|
|
58
|
+
const reviewText = yield* fs.readFile(reviewAbs)
|
|
59
|
+
fm = parseFrontMatter(reviewText)
|
|
60
|
+
}
|
|
61
|
+
const verdict = asVerdict(fm?.verdict)
|
|
62
|
+
if (verdict !== "APPROVED") {
|
|
63
|
+
return yield* new GateRefused({
|
|
64
|
+
gate: "commit",
|
|
65
|
+
message: `commit refused: code review verdict is ${fm?.verdict ?? "missing"} (need APPROVED)`,
|
|
66
|
+
details: {
|
|
67
|
+
review: reviewRel,
|
|
68
|
+
verdict: fm?.verdict ?? "missing",
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pkgRel =
|
|
74
|
+
state.current_phase_package ??
|
|
75
|
+
rel(
|
|
76
|
+
path.join(phaseDir(state.phase_index, 1, root), "phase-package.md"),
|
|
77
|
+
root,
|
|
78
|
+
)
|
|
79
|
+
const pkgAbs = abs(pkgRel, root)
|
|
80
|
+
const pkgPresent = yield* fs.exists(pkgAbs)
|
|
81
|
+
if (!pkgPresent) {
|
|
82
|
+
return yield* new GateRefused({
|
|
83
|
+
gate: "commit",
|
|
84
|
+
message: `phase package missing: ${pkgRel}`,
|
|
85
|
+
details: { package: pkgRel },
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
const pkgText = yield* fs.readFile(pkgAbs)
|
|
89
|
+
const cmds = extractVerifyCommands(pkgText)
|
|
90
|
+
if (!cmds.length) {
|
|
91
|
+
return yield* new ArtifactInvalid({
|
|
92
|
+
artifact: pkgRel,
|
|
93
|
+
message: "no verify commands found in phase package (need ```sh block)",
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cfg = yield* config.load(root)
|
|
98
|
+
const verifyTimeout = cfg.timeouts_ms.verify ?? 900_000
|
|
99
|
+
const verify = yield* vcs.runVerify(root, cmds, verifyTimeout)
|
|
100
|
+
|
|
101
|
+
const vlog = path.join(path.dirname(reviewAbs), "verify.log")
|
|
102
|
+
yield* fs.mkdir(path.dirname(vlog), { recursive: true })
|
|
103
|
+
yield* fs.writeFile(vlog, `${verify.log}\n`)
|
|
104
|
+
|
|
105
|
+
if (!verify.ok) {
|
|
106
|
+
return yield* new VerifyFailed({
|
|
107
|
+
commands: cmds,
|
|
108
|
+
outputs: [verify.log.slice(-2000)],
|
|
109
|
+
// `outputs` is a tail — point the caller at the full log on disk.
|
|
110
|
+
verify_log: rel(vlog, root),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const message =
|
|
115
|
+
params.message?.trim() ||
|
|
116
|
+
`feat: apnea phase ${state.phase_index} (${state.slug})`
|
|
117
|
+
|
|
118
|
+
const detail = yield* vcs.commitPhase(root, state.vcs, message)
|
|
119
|
+
|
|
120
|
+
if (params.no_remaining_phases) {
|
|
121
|
+
state.step = "finishing"
|
|
122
|
+
if (state.vcs === "jj") {
|
|
123
|
+
yield* vcs.setBookmarkAtTerminus(root, state.slug)
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
state.phase_index += 1
|
|
127
|
+
state.step = "phase_packaging"
|
|
128
|
+
state.current_phase_package = null
|
|
129
|
+
state.current_code_review = null
|
|
130
|
+
}
|
|
131
|
+
state.last_error = null
|
|
132
|
+
yield* store.save(state, root)
|
|
133
|
+
|
|
134
|
+
return ok(
|
|
135
|
+
`committed phase; step → ${state.step}`,
|
|
136
|
+
{
|
|
137
|
+
vcs_detail: detail,
|
|
138
|
+
verify_log: rel(vlog, root),
|
|
139
|
+
step: state.step,
|
|
140
|
+
phase_index: state.phase_index,
|
|
141
|
+
next:
|
|
142
|
+
state.step === "finishing"
|
|
143
|
+
? ["dispatch_role kind=pr_description"]
|
|
144
|
+
: ["dispatch_role kind=phase_package"],
|
|
145
|
+
},
|
|
146
|
+
nextAfter(state.step),
|
|
147
|
+
)
|
|
148
|
+
})
|