@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,315 @@
|
|
|
1
|
+
import { Result, Schema } from "effect"
|
|
2
|
+
import { ConfigError } from "../errors.ts"
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_TIMEOUTS,
|
|
5
|
+
ROLE_MODE,
|
|
6
|
+
type ApneaConfig,
|
|
7
|
+
type PaneStyle,
|
|
8
|
+
type Profile,
|
|
9
|
+
type Role,
|
|
10
|
+
type RoleMode,
|
|
11
|
+
} from "../domain/types.ts"
|
|
12
|
+
|
|
13
|
+
const StringArray = Schema.Array(Schema.String)
|
|
14
|
+
|
|
15
|
+
const ProfileSchema = Schema.Struct({
|
|
16
|
+
cmd_oneshot: Schema.optional(StringArray),
|
|
17
|
+
cmd_interactive: Schema.optional(StringArray),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const RoleBindingSchema = Schema.Struct({
|
|
21
|
+
profile: Schema.String.check(Schema.isMinLength(1)),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const PaneStyleSchema = Schema.Literals(["regular", "floating"] as const)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mirrors `schemas/config.schema.json` top-level keys.
|
|
28
|
+
*
|
|
29
|
+
* `review_round_cap` / `timeouts_ms` carry no range `check`: an out-of-range
|
|
30
|
+
* number must fall back to the default (see below), not fail the decode. A
|
|
31
|
+
* hard failure here bricks every tool until the file is hand-edited.
|
|
32
|
+
*/
|
|
33
|
+
export const GlobalConfigSchema = Schema.Struct({
|
|
34
|
+
profiles: Schema.optional(Schema.Record(Schema.String, ProfileSchema)),
|
|
35
|
+
roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
|
|
36
|
+
review_round_cap: Schema.optional(Schema.Number),
|
|
37
|
+
timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
|
|
38
|
+
pane_style: Schema.optional(PaneStyleSchema),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const PROJECT_KNOWN = new Set([
|
|
42
|
+
"roles",
|
|
43
|
+
"review_round_cap",
|
|
44
|
+
"timeouts_ms",
|
|
45
|
+
"isolation",
|
|
46
|
+
"pane_style",
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
const PROJECT_FORBIDDEN = new Set([
|
|
50
|
+
"cmd",
|
|
51
|
+
"cmd_oneshot",
|
|
52
|
+
"cmd_interactive",
|
|
53
|
+
"bin",
|
|
54
|
+
"profiles",
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Project overlay — unknown keys and profile-owned keys fail decode.
|
|
59
|
+
* Range-tolerant for the same reason as `GlobalConfigSchema`.
|
|
60
|
+
*/
|
|
61
|
+
export const ProjectConfigSchema = Schema.Struct({
|
|
62
|
+
roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
|
|
63
|
+
review_round_cap: Schema.optional(Schema.Number),
|
|
64
|
+
timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
|
|
65
|
+
isolation: Schema.optional(Schema.Literal("shared_cwd")),
|
|
66
|
+
pane_style: Schema.optional(PaneStyleSchema),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
function configFail(
|
|
70
|
+
message: string,
|
|
71
|
+
path?: string,
|
|
72
|
+
): Result.Result<never, ConfigError> {
|
|
73
|
+
return Result.fail(
|
|
74
|
+
path !== undefined
|
|
75
|
+
? new ConfigError({ message, path })
|
|
76
|
+
: new ConfigError({ message }),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function asObject(
|
|
81
|
+
v: unknown,
|
|
82
|
+
label: string,
|
|
83
|
+
): Result.Result<Record<string, unknown>, ConfigError> {
|
|
84
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
85
|
+
return configFail(`${label} must be a JSON object`)
|
|
86
|
+
}
|
|
87
|
+
return Result.succeed(v as Record<string, unknown>)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Decode + apply parseGlobal defaults into ApneaConfig. */
|
|
91
|
+
export function decodeGlobalConfig(
|
|
92
|
+
raw: unknown,
|
|
93
|
+
): Result.Result<ApneaConfig, ConfigError> {
|
|
94
|
+
const objR = asObject(raw, "global config")
|
|
95
|
+
if (Result.isFailure(objR)) return configFail(objR.failure.message)
|
|
96
|
+
|
|
97
|
+
const obj = objR.success
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
"isolation" in obj &&
|
|
101
|
+
obj.isolation !== undefined &&
|
|
102
|
+
obj.isolation !== "shared_cwd"
|
|
103
|
+
) {
|
|
104
|
+
return configFail(
|
|
105
|
+
`unimplemented config value isolation=${JSON.stringify(obj.isolation)} (v1 only supports shared_cwd or omit)`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Reject cmd-like keys nested under roles before struct decode.
|
|
110
|
+
if (obj.roles && typeof obj.roles === "object" && !Array.isArray(obj.roles)) {
|
|
111
|
+
for (const [k, v] of Object.entries(obj.roles as Record<string, unknown>)) {
|
|
112
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) continue
|
|
113
|
+
const r = v as Record<string, unknown>
|
|
114
|
+
for (const bad of PROJECT_FORBIDDEN) {
|
|
115
|
+
if (bad !== "profiles" && bad in r) {
|
|
116
|
+
return configFail(`roles.${k} must not include ${bad}; use profiles`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(obj)
|
|
123
|
+
if (Result.isFailure(decoded)) {
|
|
124
|
+
return configFail(decoded.failure.message)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const d = decoded.success
|
|
128
|
+
const profiles: Record<string, Profile> = {}
|
|
129
|
+
for (const [name, p] of Object.entries(d.profiles ?? {})) {
|
|
130
|
+
const out: Profile = {}
|
|
131
|
+
if (p.cmd_oneshot) out.cmd_oneshot = [...p.cmd_oneshot]
|
|
132
|
+
if (p.cmd_interactive) out.cmd_interactive = [...p.cmd_interactive]
|
|
133
|
+
if (!out.cmd_oneshot?.length && !out.cmd_interactive?.length) {
|
|
134
|
+
return configFail(
|
|
135
|
+
`profile ${name} needs cmd_oneshot and/or cmd_interactive`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
profiles[name] = out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const roles: Record<string, { profile: string }> = {}
|
|
142
|
+
for (const [k, v] of Object.entries(d.roles ?? {})) {
|
|
143
|
+
roles[k] = { profile: v.profile }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const timeouts = { ...DEFAULT_TIMEOUTS }
|
|
147
|
+
if (d.timeouts_ms) {
|
|
148
|
+
for (const [k, v] of Object.entries(d.timeouts_ms)) {
|
|
149
|
+
if (typeof v === "number" && v >= 1000) timeouts[k] = v
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const pane_style: PaneStyle =
|
|
154
|
+
d.pane_style === "regular" || d.pane_style === "floating"
|
|
155
|
+
? d.pane_style
|
|
156
|
+
: "regular"
|
|
157
|
+
|
|
158
|
+
return Result.succeed({
|
|
159
|
+
profiles,
|
|
160
|
+
roles,
|
|
161
|
+
review_round_cap:
|
|
162
|
+
typeof d.review_round_cap === "number" && d.review_round_cap >= 1
|
|
163
|
+
? d.review_round_cap
|
|
164
|
+
: 3,
|
|
165
|
+
timeouts_ms: timeouts,
|
|
166
|
+
pane_style,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Validate project overlay (unknown keys fail). Does not merge. */
|
|
171
|
+
export function decodeProjectConfig(
|
|
172
|
+
raw: unknown,
|
|
173
|
+
): Result.Result<typeof ProjectConfigSchema.Type, ConfigError> {
|
|
174
|
+
if (raw == null) {
|
|
175
|
+
return Result.succeed({})
|
|
176
|
+
}
|
|
177
|
+
const objR = asObject(raw, "project config")
|
|
178
|
+
if (Result.isFailure(objR)) return configFail(objR.failure.message)
|
|
179
|
+
|
|
180
|
+
const obj = objR.success
|
|
181
|
+
|
|
182
|
+
for (const key of Object.keys(obj)) {
|
|
183
|
+
if (PROJECT_FORBIDDEN.has(key)) {
|
|
184
|
+
return configFail(
|
|
185
|
+
`project config must not set ${key} (binaries/profiles only allowed in global config)`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (!PROJECT_KNOWN.has(key)) {
|
|
189
|
+
return configFail(`unknown project config key: ${key}`)
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
key === "isolation" &&
|
|
193
|
+
obj.isolation !== "shared_cwd" &&
|
|
194
|
+
obj.isolation !== undefined
|
|
195
|
+
) {
|
|
196
|
+
return configFail(
|
|
197
|
+
`unimplemented isolation=${JSON.stringify(obj.isolation)}`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// roles must not carry profile-owned keys
|
|
203
|
+
if (obj.roles && typeof obj.roles === "object" && !Array.isArray(obj.roles)) {
|
|
204
|
+
for (const [k, v] of Object.entries(obj.roles as Record<string, unknown>)) {
|
|
205
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
206
|
+
return configFail(`project roles.${k} must be a JSON object`)
|
|
207
|
+
}
|
|
208
|
+
const r = v as Record<string, unknown>
|
|
209
|
+
for (const bad of ["cmd", "cmd_oneshot", "cmd_interactive", "bin"]) {
|
|
210
|
+
if (bad in r) {
|
|
211
|
+
return configFail(`project roles.${k} must not set ${bad}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(obj, {
|
|
218
|
+
onExcessProperty: "error",
|
|
219
|
+
})
|
|
220
|
+
if (Result.isFailure(decoded)) {
|
|
221
|
+
return configFail(decoded.failure.message)
|
|
222
|
+
}
|
|
223
|
+
return Result.succeed(decoded.success)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Merge a validated project overlay onto a base config.
|
|
228
|
+
* Profiles always stay from the base (global-only).
|
|
229
|
+
*/
|
|
230
|
+
export function applyProjectConfig(
|
|
231
|
+
cfg: ApneaConfig,
|
|
232
|
+
overlay: typeof ProjectConfigSchema.Type,
|
|
233
|
+
): ApneaConfig {
|
|
234
|
+
const roles = { ...cfg.roles }
|
|
235
|
+
if (overlay.roles) {
|
|
236
|
+
for (const [k, v] of Object.entries(overlay.roles)) {
|
|
237
|
+
roles[k] = { profile: v.profile }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const timeouts = { ...cfg.timeouts_ms }
|
|
241
|
+
if (overlay.timeouts_ms) {
|
|
242
|
+
for (const [k, v] of Object.entries(overlay.timeouts_ms)) {
|
|
243
|
+
if (typeof v === "number" && v >= 1000) timeouts[k] = v
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
profiles: cfg.profiles,
|
|
248
|
+
roles,
|
|
249
|
+
review_round_cap:
|
|
250
|
+
overlay.review_round_cap !== undefined && overlay.review_round_cap >= 1
|
|
251
|
+
? overlay.review_round_cap
|
|
252
|
+
: cfg.review_round_cap,
|
|
253
|
+
timeouts_ms: timeouts,
|
|
254
|
+
pane_style:
|
|
255
|
+
overlay.pane_style !== undefined ? overlay.pane_style : cfg.pane_style,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve role → profile → `cmd_<mode>`. Shared by `ConfigLive` and the test
|
|
261
|
+
* fake so tests exercise the real resolution instead of a copy of it.
|
|
262
|
+
*
|
|
263
|
+
* Deliberately distinct from `validateRoleBindings` below: this is the runtime
|
|
264
|
+
* lookup, that one is the setup-time audit and phrases the same failures as
|
|
265
|
+
* actionable config guidance.
|
|
266
|
+
*/
|
|
267
|
+
export function resolveRoleCmdResult(
|
|
268
|
+
cfg: ApneaConfig,
|
|
269
|
+
role: Role,
|
|
270
|
+
mode: RoleMode = ROLE_MODE[role],
|
|
271
|
+
): Result.Result<string[], ConfigError> {
|
|
272
|
+
const binding = cfg.roles[role]
|
|
273
|
+
if (!binding) {
|
|
274
|
+
return configFail(`no role binding for ${role}`)
|
|
275
|
+
}
|
|
276
|
+
const profile = cfg.profiles[binding.profile]
|
|
277
|
+
if (!profile) {
|
|
278
|
+
return configFail(`unknown profile ${binding.profile}`)
|
|
279
|
+
}
|
|
280
|
+
const cmd = mode === "oneshot" ? profile.cmd_oneshot : profile.cmd_interactive
|
|
281
|
+
if (!cmd?.length) {
|
|
282
|
+
return configFail(`profile ${binding.profile} has no cmd_${mode}`)
|
|
283
|
+
}
|
|
284
|
+
return Result.succeed([...cmd])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Validate planner/reviewer/coder role→profile bindings and required cmds.
|
|
289
|
+
* Success returns cfg unchanged.
|
|
290
|
+
*/
|
|
291
|
+
export function validateRoleBindings(
|
|
292
|
+
cfg: ApneaConfig,
|
|
293
|
+
): Result.Result<ApneaConfig, ConfigError> {
|
|
294
|
+
for (const role of ["planner", "reviewer", "coder"] as Role[]) {
|
|
295
|
+
const binding = cfg.roles[role]
|
|
296
|
+
if (!binding) {
|
|
297
|
+
return configFail(`config missing roles.${role}`)
|
|
298
|
+
}
|
|
299
|
+
const profile = cfg.profiles[binding.profile]
|
|
300
|
+
if (!profile) {
|
|
301
|
+
return configFail(
|
|
302
|
+
`roles.${role} profile "${binding.profile}" not defined in global profiles`,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
const mode = ROLE_MODE[role]
|
|
306
|
+
const cmd =
|
|
307
|
+
mode === "oneshot" ? profile.cmd_oneshot : profile.cmd_interactive
|
|
308
|
+
if (!cmd?.length) {
|
|
309
|
+
return configFail(
|
|
310
|
+
`profile "${binding.profile}" missing cmd_${mode} required by role ${role}`,
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return Result.succeed(cfg)
|
|
315
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Result, Schema } from "effect"
|
|
2
|
+
import { ArtifactInvalid } from "../errors.ts"
|
|
3
|
+
|
|
4
|
+
const VerdictSchema = Schema.Literals(["APPROVED", "CHANGES_REQUIRED"] as const)
|
|
5
|
+
const ReworkTargetSchema = Schema.Literals(["code", "phase_package"] as const)
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Schema for the parsed front-matter result shape (after the line parser).
|
|
9
|
+
* Consumed by wait in Phase 4.
|
|
10
|
+
*/
|
|
11
|
+
export const FrontMatterResultSchema = Schema.Struct({
|
|
12
|
+
status: Schema.String,
|
|
13
|
+
verdict: Schema.optional(VerdictSchema),
|
|
14
|
+
nits: Schema.optional(Schema.String),
|
|
15
|
+
rework: Schema.optional(ReworkTargetSchema),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export type FrontMatterResult = typeof FrontMatterResultSchema.Type
|
|
19
|
+
|
|
20
|
+
export function decodeFrontMatterResult(
|
|
21
|
+
raw: unknown,
|
|
22
|
+
artifact = "artifact",
|
|
23
|
+
): Result.Result<FrontMatterResult, ArtifactInvalid> {
|
|
24
|
+
const decoded = Schema.decodeUnknownResult(FrontMatterResultSchema)(raw)
|
|
25
|
+
if (Result.isFailure(decoded)) {
|
|
26
|
+
return Result.fail(
|
|
27
|
+
new ArtifactInvalid({
|
|
28
|
+
artifact,
|
|
29
|
+
message: decoded.failure.message,
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
return Result.succeed(decoded.success)
|
|
34
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Result, Schema } from "effect"
|
|
2
|
+
import { StateCorrupt } from "../errors.ts"
|
|
3
|
+
import type { RunState, Step } from "../domain/types.ts"
|
|
4
|
+
|
|
5
|
+
export const StepSchema = Schema.Literals([
|
|
6
|
+
"planning",
|
|
7
|
+
"plan_review",
|
|
8
|
+
"phase_packaging",
|
|
9
|
+
"coding",
|
|
10
|
+
"code_review",
|
|
11
|
+
"committing",
|
|
12
|
+
"finishing",
|
|
13
|
+
"done",
|
|
14
|
+
] as const)
|
|
15
|
+
|
|
16
|
+
export const VcsBackendSchema = Schema.Literals(["jj", "git"] as const)
|
|
17
|
+
|
|
18
|
+
export const RoleSchema = Schema.Literals([
|
|
19
|
+
"orchestrator",
|
|
20
|
+
"planner",
|
|
21
|
+
"reviewer",
|
|
22
|
+
"coder",
|
|
23
|
+
] as const)
|
|
24
|
+
|
|
25
|
+
const PaneRefSchema = Schema.Struct({
|
|
26
|
+
pane_id: Schema.String,
|
|
27
|
+
label: Schema.String,
|
|
28
|
+
profile_fingerprint: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runtime codec for `state.json` (version 1).
|
|
33
|
+
* Missing pane-tracking fields are filled in `decodeRunState` (legacy files).
|
|
34
|
+
*/
|
|
35
|
+
export const RunStateSchema = Schema.Struct({
|
|
36
|
+
version: Schema.Literal(1),
|
|
37
|
+
slug: Schema.String.check(Schema.isMinLength(1)),
|
|
38
|
+
step: StepSchema,
|
|
39
|
+
phase_index: Schema.Number,
|
|
40
|
+
phase_count_hint: Schema.NullOr(Schema.Number),
|
|
41
|
+
rounds: Schema.Record(Schema.String, Schema.Number),
|
|
42
|
+
vcs: VcsBackendSchema,
|
|
43
|
+
allow_dirty: Schema.Boolean,
|
|
44
|
+
goal: Schema.String,
|
|
45
|
+
last_error: Schema.NullOr(Schema.String),
|
|
46
|
+
pending_artifact: Schema.NullOr(Schema.String),
|
|
47
|
+
pending_role: Schema.NullOr(RoleSchema),
|
|
48
|
+
// optional on Encoded so legacy fixtures without pane fields still decode
|
|
49
|
+
pending_pane_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
50
|
+
pending_pane_label: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
51
|
+
/** Mirrored in `schemas/state.schema.json`; drift is caught by schema.test.ts. */
|
|
52
|
+
pending_floating_exit: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
53
|
+
pending_started_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
|
|
54
|
+
pending_deadline_ms: Schema.optionalKey(Schema.NullOr(Schema.Number)),
|
|
55
|
+
pending_nudged_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
|
|
56
|
+
pending_final_grace: Schema.optionalKey(Schema.Boolean),
|
|
57
|
+
pending_extended: Schema.optionalKey(Schema.Boolean),
|
|
58
|
+
role_panes: Schema.optionalKey(Schema.Record(Schema.String, PaneRefSchema)),
|
|
59
|
+
package_root: Schema.String,
|
|
60
|
+
reviewer_tree_fingerprint: Schema.NullOr(Schema.String),
|
|
61
|
+
current_phase_package: Schema.NullOr(Schema.String),
|
|
62
|
+
current_code_review: Schema.NullOr(Schema.String),
|
|
63
|
+
phase_package_rework: Schema.optionalKey(Schema.Boolean),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export type DecodedRunState = typeof RunStateSchema.Type
|
|
67
|
+
|
|
68
|
+
export function decodeRunState(
|
|
69
|
+
json: unknown,
|
|
70
|
+
path = "state.json",
|
|
71
|
+
): Result.Result<RunState, StateCorrupt> {
|
|
72
|
+
const decoded = Schema.decodeUnknownResult(RunStateSchema)(json)
|
|
73
|
+
if (Result.isFailure(decoded)) {
|
|
74
|
+
return Result.fail(
|
|
75
|
+
new StateCorrupt({
|
|
76
|
+
path,
|
|
77
|
+
message: decoded.failure.message,
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const d = decoded.success
|
|
82
|
+
// Backward-compat defaults for state.json files predating pane tracking.
|
|
83
|
+
const state: RunState = {
|
|
84
|
+
version: 1,
|
|
85
|
+
slug: d.slug,
|
|
86
|
+
step: d.step as Step,
|
|
87
|
+
phase_index: d.phase_index,
|
|
88
|
+
phase_count_hint: d.phase_count_hint,
|
|
89
|
+
rounds: { ...d.rounds },
|
|
90
|
+
vcs: d.vcs,
|
|
91
|
+
allow_dirty: d.allow_dirty,
|
|
92
|
+
goal: d.goal,
|
|
93
|
+
last_error: d.last_error,
|
|
94
|
+
pending_artifact: d.pending_artifact,
|
|
95
|
+
pending_role: d.pending_role,
|
|
96
|
+
pending_pane_id: d.pending_pane_id ?? null,
|
|
97
|
+
pending_pane_label: d.pending_pane_label ?? null,
|
|
98
|
+
pending_floating_exit: d.pending_floating_exit ?? null,
|
|
99
|
+
pending_started_at: d.pending_started_at ?? null,
|
|
100
|
+
pending_deadline_ms: d.pending_deadline_ms ?? null,
|
|
101
|
+
pending_nudged_at: d.pending_nudged_at ?? null,
|
|
102
|
+
pending_final_grace: d.pending_final_grace ?? false,
|
|
103
|
+
pending_extended: d.pending_extended ?? false,
|
|
104
|
+
role_panes: Object.fromEntries(
|
|
105
|
+
Object.entries(d.role_panes ?? {}).map(([role, pane]) => [
|
|
106
|
+
role,
|
|
107
|
+
{ ...pane, profile_fingerprint: pane.profile_fingerprint ?? null },
|
|
108
|
+
]),
|
|
109
|
+
),
|
|
110
|
+
package_root: d.package_root,
|
|
111
|
+
reviewer_tree_fingerprint: d.reviewer_tree_fingerprint,
|
|
112
|
+
current_phase_package: d.current_phase_package,
|
|
113
|
+
current_code_review: d.current_code_review,
|
|
114
|
+
phase_package_rework: d.phase_package_rework ?? false,
|
|
115
|
+
}
|
|
116
|
+
return Result.succeed(state)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type { Step }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Layer } from "effect"
|
|
2
|
+
import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
|
|
3
|
+
import { ConfigLive } from "./config.ts"
|
|
4
|
+
import { FileSystemLive } from "./file-system.ts"
|
|
5
|
+
import { makeHerdrLive } from "./herdr.ts"
|
|
6
|
+
import { RunStoreLive } from "./run-store.ts"
|
|
7
|
+
import { VcsLive } from "./vcs.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Blueprint layer for tool calls. Built freshly on every `Effect.provide`
|
|
11
|
+
* inside runTool — no module-level ManagedRuntime.
|
|
12
|
+
*/
|
|
13
|
+
export const makeAppLive = (hostAdapter: ApneaHostAdapter) =>
|
|
14
|
+
Layer.provideMerge(
|
|
15
|
+
Layer.mergeAll(
|
|
16
|
+
RunStoreLive,
|
|
17
|
+
ConfigLive,
|
|
18
|
+
VcsLive,
|
|
19
|
+
makeHerdrLive(hostAdapter),
|
|
20
|
+
),
|
|
21
|
+
FileSystemLive,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export const AppLive = makeAppLive(neutralHostAdapter)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Result } from "effect"
|
|
2
|
+
import { globalConfigPath, projectConfigPath } from "../domain/paths.ts"
|
|
3
|
+
import { ConfigError } from "../errors.ts"
|
|
4
|
+
import {
|
|
5
|
+
ROLE_MODE,
|
|
6
|
+
type ApneaConfig,
|
|
7
|
+
type Role,
|
|
8
|
+
type RoleMode,
|
|
9
|
+
} from "../domain/types.ts"
|
|
10
|
+
import {
|
|
11
|
+
applyProjectConfig,
|
|
12
|
+
decodeGlobalConfig,
|
|
13
|
+
decodeProjectConfig,
|
|
14
|
+
resolveRoleCmdResult,
|
|
15
|
+
validateRoleBindings,
|
|
16
|
+
} from "../schema/config.ts"
|
|
17
|
+
import { FileSystem } from "./file-system.ts"
|
|
18
|
+
|
|
19
|
+
export interface ConfigService {
|
|
20
|
+
readonly load: (root: string) => Effect.Effect<ApneaConfig, ConfigError>
|
|
21
|
+
readonly resolveRoleCmd: (
|
|
22
|
+
cfg: ApneaConfig,
|
|
23
|
+
role: Role,
|
|
24
|
+
mode?: RoleMode,
|
|
25
|
+
) => Effect.Effect<string[], ConfigError>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Config extends Context.Service<Config, ConfigService>()(
|
|
29
|
+
"apnea/Config",
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
function parseJson(
|
|
33
|
+
text: string,
|
|
34
|
+
filePath: string,
|
|
35
|
+
): Effect.Effect<unknown, ConfigError> {
|
|
36
|
+
return Effect.try({
|
|
37
|
+
try: () => JSON.parse(text) as unknown,
|
|
38
|
+
catch: (e) =>
|
|
39
|
+
new ConfigError({
|
|
40
|
+
message: `invalid JSON at ${filePath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
41
|
+
path: filePath,
|
|
42
|
+
}),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ConfigLive = Layer.effect(
|
|
47
|
+
Config,
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const fs = yield* FileSystem
|
|
50
|
+
|
|
51
|
+
const load = (root: string): Effect.Effect<ApneaConfig, ConfigError> =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const gPath = globalConfigPath()
|
|
54
|
+
const gPresent = yield* fs.exists(gPath)
|
|
55
|
+
if (!gPresent) {
|
|
56
|
+
return yield* new ConfigError({
|
|
57
|
+
message: `missing global config at ${gPath}. Run apnea-setup / create profiles there.`,
|
|
58
|
+
path: gPath,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
const gText = yield* fs.readFile(gPath)
|
|
62
|
+
const gRaw = yield* parseJson(gText, gPath)
|
|
63
|
+
const gDecoded = decodeGlobalConfig(gRaw)
|
|
64
|
+
if (Result.isFailure(gDecoded)) {
|
|
65
|
+
return yield* gDecoded.failure
|
|
66
|
+
}
|
|
67
|
+
let cfg = gDecoded.success
|
|
68
|
+
|
|
69
|
+
const pPath = projectConfigPath(root)
|
|
70
|
+
const pPresent = yield* fs.exists(pPath)
|
|
71
|
+
if (pPresent) {
|
|
72
|
+
const pText = yield* fs.readFile(pPath)
|
|
73
|
+
const pRaw = yield* parseJson(pText, pPath)
|
|
74
|
+
const pDecoded = decodeProjectConfig(pRaw)
|
|
75
|
+
if (Result.isFailure(pDecoded)) {
|
|
76
|
+
return yield* pDecoded.failure
|
|
77
|
+
}
|
|
78
|
+
cfg = applyProjectConfig(cfg, pDecoded.success)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const validated = validateRoleBindings(cfg)
|
|
82
|
+
if (Result.isFailure(validated)) {
|
|
83
|
+
return yield* validated.failure
|
|
84
|
+
}
|
|
85
|
+
return validated.success
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const resolveRoleCmd = (
|
|
89
|
+
cfg: ApneaConfig,
|
|
90
|
+
role: Role,
|
|
91
|
+
mode: RoleMode = ROLE_MODE[role],
|
|
92
|
+
): Effect.Effect<string[], ConfigError> =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const resolved = resolveRoleCmdResult(cfg, role, mode)
|
|
95
|
+
if (Result.isFailure(resolved)) {
|
|
96
|
+
return yield* resolved.failure
|
|
97
|
+
}
|
|
98
|
+
return resolved.success
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return Config.of({ load, resolveRoleCmd })
|
|
102
|
+
}),
|
|
103
|
+
)
|