@naxodev/apnea 0.1.0 → 0.2.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.
- package/README.md +25 -10
- package/SECURITY.md +32 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8571 -15324
- package/docs/adr/0005-harness-profiles.md +1 -1
- package/docs/adr/0010-package-split.md +1 -1
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +22 -25
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +18 -5
- package/extension/adapters/commit.ts +5 -1
- package/extension/adapters/dispatch.ts +9 -1
- package/extension/adapters/setup.ts +15 -1
- package/extension/adapters/start.ts +5 -1
- package/extension/adapters/status.ts +17 -2
- package/extension/adapters/wait.ts +6 -1
- package/extension/api.ts +7 -1
- package/extension/cli/main.ts +67 -7
- package/extension/cli/parse.ts +172 -5
- package/extension/domain/herdr.ts +0 -86
- package/extension/domain/paths.ts +3 -13
- package/extension/domain/setup.ts +0 -20
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +64 -11
- package/extension/domain/verify-commands.ts +200 -108
- package/extension/errors.ts +51 -16
- package/extension/operation-hooks.ts +6 -0
- package/extension/registry.ts +29 -15
- package/extension/run-tool.ts +19 -2
- package/extension/schema/config.ts +86 -30
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +226 -16
- package/extension/services/app-live.ts +2 -1
- package/extension/services/config.ts +6 -4
- package/extension/services/file-system.ts +346 -75
- package/extension/services/herdr.ts +393 -402
- package/extension/services/operation-lock.ts +418 -0
- package/extension/services/process.ts +477 -0
- package/extension/services/run-store.ts +38 -16
- package/extension/services/vcs.ts +1388 -86
- package/extension/workflows/commit.ts +222 -18
- package/extension/workflows/dispatch.ts +320 -220
- package/extension/workflows/setup.ts +61 -141
- package/extension/workflows/start.ts +6 -5
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +63 -134
- package/package.json +2 -3
- package/schemas/config.schema.json +11 -7
- package/schemas/state.schema.json +170 -12
- package/herdr-plugin/herdr-plugin.toml +0 -15
- package/herdr-plugin/scripts/run-task.sh +0 -8
package/extension/run-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cause, Effect, Exit, Layer, Option, Result } from "effect"
|
|
2
|
-
import { isAppError, toToolResult } from "./errors.ts"
|
|
2
|
+
import { isAppError, OperationAborted, toToolResult } from "./errors.ts"
|
|
3
3
|
import type { ToolResult } from "./result.ts"
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -12,15 +12,32 @@ export async function runToolResult<E, R>(
|
|
|
12
12
|
// without the layer would type-check and then die as a service-not-found
|
|
13
13
|
// defect on every invocation. Pass `Layer.empty` explicitly when R is never.
|
|
14
14
|
layer: Layer.Layer<R, never, never>,
|
|
15
|
+
hooks: {
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
operation?: string
|
|
18
|
+
abortDetails?: Record<string, unknown>
|
|
19
|
+
} = {},
|
|
15
20
|
): Promise<ToolResult> {
|
|
16
21
|
const provided = Effect.provide(effect, layer) as Effect.Effect<ToolResult, E>
|
|
17
|
-
const exit = await Effect.runPromiseExit(provided)
|
|
22
|
+
const exit = await Effect.runPromiseExit(provided, { signal: hooks.signal })
|
|
18
23
|
|
|
19
24
|
if (Exit.isSuccess(exit)) {
|
|
20
25
|
return exit.value
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
const error = Exit.findErrorOption(exit)
|
|
29
|
+
if (
|
|
30
|
+
hooks.signal?.aborted ||
|
|
31
|
+
Result.isSuccess(Cause.findInterrupt(exit.cause))
|
|
32
|
+
) {
|
|
33
|
+
return toToolResult(
|
|
34
|
+
new OperationAborted({
|
|
35
|
+
operation: hooks.operation ?? "operation",
|
|
36
|
+
details: hooks.abortDetails,
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
if (Option.isSome(error) && isAppError(error.value)) {
|
|
25
42
|
return toToolResult(error.value)
|
|
26
43
|
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
DEFAULT_TIMEOUTS,
|
|
5
5
|
ROLE_MODE,
|
|
6
6
|
type ApneaConfig,
|
|
7
|
-
type PaneStyle,
|
|
8
7
|
type Profile,
|
|
9
8
|
type Role,
|
|
10
9
|
type RoleMode,
|
|
@@ -21,21 +20,60 @@ const RoleBindingSchema = Schema.Struct({
|
|
|
21
20
|
profile: Schema.String.check(Schema.isMinLength(1)),
|
|
22
21
|
})
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
const ReviewRoundCapSchema = Schema.Int.check(
|
|
24
|
+
Schema.isBetween({ minimum: 1, maximum: 20 }),
|
|
25
|
+
)
|
|
26
|
+
const TimeoutMsSchema = Schema.Int.check(
|
|
27
|
+
Schema.isBetween({ minimum: 1_000, maximum: Number.MAX_SAFE_INTEGER }),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
function validReviewRoundCap(value: number): boolean {
|
|
31
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= 20
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validTimeout(value: number): boolean {
|
|
35
|
+
return Number.isSafeInteger(value) && value >= 1_000
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sanitizeConfigNumbers(
|
|
39
|
+
config: Record<string, unknown>,
|
|
40
|
+
): Record<string, unknown> {
|
|
41
|
+
const sanitized = { ...config }
|
|
42
|
+
if (
|
|
43
|
+
typeof sanitized.review_round_cap === "number" &&
|
|
44
|
+
!validReviewRoundCap(sanitized.review_round_cap)
|
|
45
|
+
) {
|
|
46
|
+
delete sanitized.review_round_cap
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
sanitized.timeouts_ms &&
|
|
50
|
+
typeof sanitized.timeouts_ms === "object" &&
|
|
51
|
+
!Array.isArray(sanitized.timeouts_ms)
|
|
52
|
+
) {
|
|
53
|
+
const timeouts = {
|
|
54
|
+
...(sanitized.timeouts_ms as Record<string, unknown>),
|
|
55
|
+
}
|
|
56
|
+
for (const [key, value] of Object.entries(timeouts)) {
|
|
57
|
+
if (typeof value === "number" && !validTimeout(value)) {
|
|
58
|
+
delete timeouts[key]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
sanitized.timeouts_ms = timeouts
|
|
62
|
+
}
|
|
63
|
+
return sanitized
|
|
64
|
+
}
|
|
25
65
|
|
|
26
66
|
/**
|
|
27
67
|
* Mirrors `schemas/config.schema.json` top-level keys.
|
|
28
68
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* hard failure here bricks every tool until the file is hand-edited.
|
|
69
|
+
* Numeric fields are strict here. The decoders remove only invalid numeric
|
|
70
|
+
* values before decoding so published 0.2 per-field fallback remains intact.
|
|
32
71
|
*/
|
|
33
72
|
export const GlobalConfigSchema = Schema.Struct({
|
|
34
73
|
profiles: Schema.optional(Schema.Record(Schema.String, ProfileSchema)),
|
|
35
74
|
roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
|
|
36
|
-
review_round_cap: Schema.optional(
|
|
37
|
-
timeouts_ms: Schema.optional(Schema.Record(Schema.String,
|
|
38
|
-
pane_style: Schema.optional(PaneStyleSchema),
|
|
75
|
+
review_round_cap: Schema.optional(ReviewRoundCapSchema),
|
|
76
|
+
timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
|
|
39
77
|
})
|
|
40
78
|
|
|
41
79
|
const PROJECT_KNOWN = new Set([
|
|
@@ -60,10 +98,9 @@ const PROJECT_FORBIDDEN = new Set([
|
|
|
60
98
|
*/
|
|
61
99
|
export const ProjectConfigSchema = Schema.Struct({
|
|
62
100
|
roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
|
|
63
|
-
review_round_cap: Schema.optional(
|
|
64
|
-
timeouts_ms: Schema.optional(Schema.Record(Schema.String,
|
|
101
|
+
review_round_cap: Schema.optional(ReviewRoundCapSchema),
|
|
102
|
+
timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
|
|
65
103
|
isolation: Schema.optional(Schema.Literal("shared_cwd")),
|
|
66
|
-
pane_style: Schema.optional(PaneStyleSchema),
|
|
67
104
|
})
|
|
68
105
|
|
|
69
106
|
function configFail(
|
|
@@ -96,6 +133,16 @@ export function decodeGlobalConfig(
|
|
|
96
133
|
|
|
97
134
|
const obj = objR.success
|
|
98
135
|
|
|
136
|
+
if (
|
|
137
|
+
"pane_style" in obj &&
|
|
138
|
+
obj.pane_style !== "regular" &&
|
|
139
|
+
obj.pane_style !== "floating"
|
|
140
|
+
) {
|
|
141
|
+
return configFail(
|
|
142
|
+
`invalid legacy pane_style=${JSON.stringify(obj.pane_style)}; expected "regular" or "floating"`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
99
146
|
if (
|
|
100
147
|
"isolation" in obj &&
|
|
101
148
|
obj.isolation !== undefined &&
|
|
@@ -119,7 +166,10 @@ export function decodeGlobalConfig(
|
|
|
119
166
|
}
|
|
120
167
|
}
|
|
121
168
|
|
|
122
|
-
const
|
|
169
|
+
const { pane_style: _legacy, ...globalConfig } = obj
|
|
170
|
+
const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(
|
|
171
|
+
sanitizeConfigNumbers(globalConfig),
|
|
172
|
+
)
|
|
123
173
|
if (Result.isFailure(decoded)) {
|
|
124
174
|
return configFail(decoded.failure.message)
|
|
125
175
|
}
|
|
@@ -146,24 +196,15 @@ export function decodeGlobalConfig(
|
|
|
146
196
|
const timeouts = { ...DEFAULT_TIMEOUTS }
|
|
147
197
|
if (d.timeouts_ms) {
|
|
148
198
|
for (const [k, v] of Object.entries(d.timeouts_ms)) {
|
|
149
|
-
if (
|
|
199
|
+
if (validTimeout(v)) timeouts[k] = v
|
|
150
200
|
}
|
|
151
201
|
}
|
|
152
202
|
|
|
153
|
-
const pane_style: PaneStyle =
|
|
154
|
-
d.pane_style === "regular" || d.pane_style === "floating"
|
|
155
|
-
? d.pane_style
|
|
156
|
-
: "regular"
|
|
157
|
-
|
|
158
203
|
return Result.succeed({
|
|
159
204
|
profiles,
|
|
160
205
|
roles,
|
|
161
|
-
review_round_cap:
|
|
162
|
-
typeof d.review_round_cap === "number" && d.review_round_cap >= 1
|
|
163
|
-
? d.review_round_cap
|
|
164
|
-
: 3,
|
|
206
|
+
review_round_cap: d.review_round_cap ?? 3,
|
|
165
207
|
timeouts_ms: timeouts,
|
|
166
|
-
pane_style,
|
|
167
208
|
})
|
|
168
209
|
}
|
|
169
210
|
|
|
@@ -179,6 +220,18 @@ export function decodeProjectConfig(
|
|
|
179
220
|
|
|
180
221
|
const obj = objR.success
|
|
181
222
|
|
|
223
|
+
// Legacy project configs may contain this retired preference. Validate it
|
|
224
|
+
// before stripping it so typos still fail instead of becoming silent no-ops.
|
|
225
|
+
if (
|
|
226
|
+
"pane_style" in obj &&
|
|
227
|
+
obj.pane_style !== "regular" &&
|
|
228
|
+
obj.pane_style !== "floating"
|
|
229
|
+
) {
|
|
230
|
+
return configFail(
|
|
231
|
+
`invalid legacy pane_style=${JSON.stringify(obj.pane_style)}; expected "regular" or "floating"`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
182
235
|
for (const key of Object.keys(obj)) {
|
|
183
236
|
if (PROJECT_FORBIDDEN.has(key)) {
|
|
184
237
|
return configFail(
|
|
@@ -214,9 +267,13 @@ export function decodeProjectConfig(
|
|
|
214
267
|
}
|
|
215
268
|
}
|
|
216
269
|
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
270
|
+
const { pane_style: _legacy, ...projectConfig } = obj
|
|
271
|
+
const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(
|
|
272
|
+
sanitizeConfigNumbers(projectConfig),
|
|
273
|
+
{
|
|
274
|
+
onExcessProperty: "error",
|
|
275
|
+
},
|
|
276
|
+
)
|
|
220
277
|
if (Result.isFailure(decoded)) {
|
|
221
278
|
return configFail(decoded.failure.message)
|
|
222
279
|
}
|
|
@@ -240,19 +297,18 @@ export function applyProjectConfig(
|
|
|
240
297
|
const timeouts = { ...cfg.timeouts_ms }
|
|
241
298
|
if (overlay.timeouts_ms) {
|
|
242
299
|
for (const [k, v] of Object.entries(overlay.timeouts_ms)) {
|
|
243
|
-
if (
|
|
300
|
+
if (validTimeout(v)) timeouts[k] = v
|
|
244
301
|
}
|
|
245
302
|
}
|
|
246
303
|
return {
|
|
247
304
|
profiles: cfg.profiles,
|
|
248
305
|
roles,
|
|
249
306
|
review_round_cap:
|
|
250
|
-
overlay.review_round_cap !== undefined &&
|
|
307
|
+
overlay.review_round_cap !== undefined &&
|
|
308
|
+
validReviewRoundCap(overlay.review_round_cap)
|
|
251
309
|
? overlay.review_round_cap
|
|
252
310
|
: cfg.review_round_cap,
|
|
253
311
|
timeouts_ms: timeouts,
|
|
254
|
-
pane_style:
|
|
255
|
-
overlay.pane_style !== undefined ? overlay.pane_style : cfg.pane_style,
|
|
256
312
|
}
|
|
257
313
|
}
|
|
258
314
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { Result, Schema } from "effect"
|
|
2
|
+
import { isCompleteArtifact } from "../domain/frontmatter.ts"
|
|
3
|
+
import {
|
|
4
|
+
stepAfterArtifact,
|
|
5
|
+
type DispatchKind,
|
|
6
|
+
} from "../domain/state-machine.ts"
|
|
7
|
+
import type { FrontMatter, ReworkTarget, Step } from "../domain/types.ts"
|
|
2
8
|
import { ArtifactInvalid } from "../errors.ts"
|
|
3
9
|
|
|
4
10
|
const VerdictSchema = Schema.Literals(["APPROVED", "CHANGES_REQUIRED"] as const)
|
|
@@ -17,6 +23,12 @@ export const FrontMatterResultSchema = Schema.Struct({
|
|
|
17
23
|
|
|
18
24
|
export type FrontMatterResult = typeof FrontMatterResultSchema.Type
|
|
19
25
|
|
|
26
|
+
export type AcceptedArtifactCompletion = {
|
|
27
|
+
frontmatter: FrontMatterResult
|
|
28
|
+
next: Step
|
|
29
|
+
rework: ReworkTarget | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
export function decodeFrontMatterResult(
|
|
21
33
|
raw: unknown,
|
|
22
34
|
artifact = "artifact",
|
|
@@ -32,3 +44,48 @@ export function decodeFrontMatterResult(
|
|
|
32
44
|
}
|
|
33
45
|
return Result.succeed(decoded.success)
|
|
34
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Shared acceptance boundary for wait advancement and redelivery refusal. */
|
|
49
|
+
export function validateArtifactCompletion(
|
|
50
|
+
kind: DispatchKind,
|
|
51
|
+
fm: FrontMatter | null,
|
|
52
|
+
artifact = "artifact",
|
|
53
|
+
): Result.Result<AcceptedArtifactCompletion | null, ArtifactInvalid> {
|
|
54
|
+
const requireVerdict = kind === "plan_review" || kind === "code_review"
|
|
55
|
+
if (!isCompleteArtifact(fm, { requireVerdict })) return Result.succeed(null)
|
|
56
|
+
|
|
57
|
+
if (
|
|
58
|
+
fm!.rework !== undefined &&
|
|
59
|
+
(kind !== "code_review" || fm!.verdict !== "CHANGES_REQUIRED")
|
|
60
|
+
) {
|
|
61
|
+
return Result.fail(
|
|
62
|
+
new ArtifactInvalid({
|
|
63
|
+
artifact,
|
|
64
|
+
message:
|
|
65
|
+
"rework is valid only on a code_review artifact with verdict CHANGES_REQUIRED",
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const decoded = decodeFrontMatterResult(
|
|
71
|
+
{
|
|
72
|
+
status: fm!.status,
|
|
73
|
+
...(requireVerdict ? { verdict: fm!.verdict } : {}),
|
|
74
|
+
nits: fm!.nits,
|
|
75
|
+
...(kind === "code_review" && fm!.rework ? { rework: fm!.rework } : {}),
|
|
76
|
+
},
|
|
77
|
+
artifact,
|
|
78
|
+
)
|
|
79
|
+
if (Result.isFailure(decoded)) return Result.fail(decoded.failure)
|
|
80
|
+
|
|
81
|
+
const rework = kind === "code_review" ? decoded.success.rework : undefined
|
|
82
|
+
const next = stepAfterArtifact(kind, fm!.verdict, rework)
|
|
83
|
+
if (typeof next === "object") {
|
|
84
|
+
return Result.fail(new ArtifactInvalid({ artifact, message: next.error }))
|
|
85
|
+
}
|
|
86
|
+
return Result.succeed({
|
|
87
|
+
frontmatter: decoded.success,
|
|
88
|
+
next,
|
|
89
|
+
rework,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Result, Schema } from "effect"
|
|
2
2
|
import { StateCorrupt } from "../errors.ts"
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
LEGACY_CODE_REWORK,
|
|
5
|
+
LEGACY_PLAN_REWORK,
|
|
6
|
+
type RequiredReworkTarget,
|
|
7
|
+
type RunState,
|
|
8
|
+
type Step,
|
|
9
|
+
} from "../domain/types.ts"
|
|
4
10
|
|
|
5
11
|
export const StepSchema = Schema.Literals([
|
|
6
12
|
"planning",
|
|
@@ -22,37 +28,94 @@ export const RoleSchema = Schema.Literals([
|
|
|
22
28
|
"coder",
|
|
23
29
|
] as const)
|
|
24
30
|
|
|
31
|
+
export const RequiredReworkSchema = Schema.NullOr(
|
|
32
|
+
Schema.Literals(["plan", "code", "phase_package"] as const),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
export const PendingDeliverySchema = Schema.NullOr(
|
|
36
|
+
Schema.Literals(["manual", "interactive"] as const),
|
|
37
|
+
)
|
|
38
|
+
|
|
25
39
|
const PaneRefSchema = Schema.Struct({
|
|
26
40
|
pane_id: Schema.String,
|
|
27
41
|
label: Schema.String,
|
|
28
42
|
profile_fingerprint: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
29
43
|
})
|
|
30
44
|
|
|
45
|
+
const PositiveSafeInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
|
|
46
|
+
const NonNegativeSafeInteger = Schema.Int.check(
|
|
47
|
+
Schema.isGreaterThanOrEqualTo(0),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const PendingCommitFields = {
|
|
51
|
+
id: Schema.String.check(Schema.isMinLength(1)),
|
|
52
|
+
phase_index: PositiveSafeInteger,
|
|
53
|
+
message: Schema.String.check(Schema.isMinLength(1)),
|
|
54
|
+
no_remaining_phases: Schema.Boolean,
|
|
55
|
+
verify_log: Schema.String.check(Schema.isMinLength(1)),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Anchor fields flow into git/jj argv, so they get format checks beyond
|
|
59
|
+
// minLength. Exploiting a loose anchor requires state.json write access
|
|
60
|
+
// (game-over elsewhere), but validating costs nothing and removes the class.
|
|
61
|
+
export const GitPendingCommitSchema = Schema.Struct({
|
|
62
|
+
backend: Schema.Literal("git"),
|
|
63
|
+
...PendingCommitFields,
|
|
64
|
+
branch: Schema.String.check(
|
|
65
|
+
Schema.isPattern(/^refs\/heads\/apnea\/[A-Za-z0-9._-]+$/),
|
|
66
|
+
),
|
|
67
|
+
parent_commit: Schema.String.check(Schema.isPattern(/^[0-9a-f]{40,64}$/)),
|
|
68
|
+
tree_id: Schema.String.check(Schema.isPattern(/^[0-9a-f]{40,64}$/)),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export const JjPendingCommitSchema = Schema.Struct({
|
|
72
|
+
backend: Schema.Literal("jj"),
|
|
73
|
+
...PendingCommitFields,
|
|
74
|
+
change_id: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9]{1,32}$/)),
|
|
75
|
+
content_fingerprint: Schema.String.check(
|
|
76
|
+
Schema.isPattern(/^[0-9a-f]{8,64}$/),
|
|
77
|
+
),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export const PendingCommitSchema = Schema.Union([
|
|
81
|
+
GitPendingCommitSchema,
|
|
82
|
+
JjPendingCommitSchema,
|
|
83
|
+
])
|
|
84
|
+
|
|
31
85
|
/**
|
|
32
|
-
* Runtime codec for `state.json
|
|
33
|
-
*
|
|
86
|
+
* Runtime codec for `state.json`.
|
|
87
|
+
*
|
|
88
|
+
* Input accepts version 1 (files written by 0.2.x) and version 2. Version-1
|
|
89
|
+
* files must not carry `pending_commit` — that is enforced in
|
|
90
|
+
* `decodeRunState`, which migrates every decoded state to version 2 with
|
|
91
|
+
* `pending_commit: null`. Version-2 files must record `pending_commit`
|
|
92
|
+
* explicitly, so a truncated or hand-edited v2 file fails closed instead of
|
|
93
|
+
* silently losing a durable commit transaction.
|
|
34
94
|
*/
|
|
35
95
|
export const RunStateSchema = Schema.Struct({
|
|
36
|
-
|
|
96
|
+
// v1 on Encoded so legacy files still decode; decodeRunState always
|
|
97
|
+
// outputs version 2.
|
|
98
|
+
version: Schema.Union([Schema.Literal(1), Schema.Literal(2)]),
|
|
37
99
|
slug: Schema.String.check(Schema.isMinLength(1)),
|
|
38
100
|
step: StepSchema,
|
|
39
|
-
phase_index:
|
|
40
|
-
phase_count_hint: Schema.NullOr(
|
|
41
|
-
rounds: Schema.Record(Schema.String,
|
|
101
|
+
phase_index: PositiveSafeInteger,
|
|
102
|
+
phase_count_hint: Schema.NullOr(NonNegativeSafeInteger),
|
|
103
|
+
rounds: Schema.Record(Schema.String, PositiveSafeInteger),
|
|
42
104
|
vcs: VcsBackendSchema,
|
|
43
105
|
allow_dirty: Schema.Boolean,
|
|
44
106
|
goal: Schema.String,
|
|
45
107
|
last_error: Schema.NullOr(Schema.String),
|
|
46
108
|
pending_artifact: Schema.NullOr(Schema.String),
|
|
47
109
|
pending_role: Schema.NullOr(RoleSchema),
|
|
110
|
+
pending_delivery: Schema.optionalKey(PendingDeliverySchema),
|
|
48
111
|
// optional on Encoded so legacy fixtures without pane fields still decode
|
|
49
112
|
pending_pane_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
50
113
|
pending_pane_label: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
pending_nudged_at: Schema.optionalKey(Schema.NullOr(
|
|
114
|
+
pending_started_at: Schema.optionalKey(Schema.NullOr(NonNegativeSafeInteger)),
|
|
115
|
+
pending_deadline_ms: Schema.optionalKey(
|
|
116
|
+
Schema.NullOr(NonNegativeSafeInteger),
|
|
117
|
+
),
|
|
118
|
+
pending_nudged_at: Schema.optionalKey(Schema.NullOr(NonNegativeSafeInteger)),
|
|
56
119
|
pending_final_grace: Schema.optionalKey(Schema.Boolean),
|
|
57
120
|
pending_extended: Schema.optionalKey(Schema.Boolean),
|
|
58
121
|
role_panes: Schema.optionalKey(Schema.Record(Schema.String, PaneRefSchema)),
|
|
@@ -60,15 +123,66 @@ export const RunStateSchema = Schema.Struct({
|
|
|
60
123
|
reviewer_tree_fingerprint: Schema.NullOr(Schema.String),
|
|
61
124
|
current_phase_package: Schema.NullOr(Schema.String),
|
|
62
125
|
current_code_review: Schema.NullOr(Schema.String),
|
|
63
|
-
|
|
126
|
+
required_rework: Schema.optionalKey(RequiredReworkSchema),
|
|
127
|
+
// optional on Encoded so version-1 files without the key still decode;
|
|
128
|
+
// presence rules per version are enforced in `decodeRunState`.
|
|
129
|
+
pending_commit: Schema.optionalKey(Schema.NullOr(PendingCommitSchema)),
|
|
64
130
|
})
|
|
65
131
|
|
|
66
132
|
export type DecodedRunState = typeof RunStateSchema.Type
|
|
67
133
|
|
|
134
|
+
function isPersistedArtifactPath(value: string): boolean {
|
|
135
|
+
if (
|
|
136
|
+
!value.startsWith(".apnea/") ||
|
|
137
|
+
value.includes("\\") ||
|
|
138
|
+
value.includes("\0")
|
|
139
|
+
) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
return value
|
|
143
|
+
.slice(".apnea/".length)
|
|
144
|
+
.split("/")
|
|
145
|
+
.every((part) => part !== "" && part !== "." && part !== "..")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hasMatchingPendingCoderDispatch(d: DecodedRunState): boolean {
|
|
149
|
+
const phase = String(d.phase_index).padStart(2, "0")
|
|
150
|
+
const round = d.rounds[`phase-${phase}/code_review`] ?? 1
|
|
151
|
+
return (
|
|
152
|
+
d.pending_role === "coder" &&
|
|
153
|
+
d.pending_artifact ===
|
|
154
|
+
`.apnea/artifacts/phase-${phase}/round-${round}/coder-result.md`
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
68
158
|
export function decodeRunState(
|
|
69
159
|
json: unknown,
|
|
70
160
|
path = "state.json",
|
|
71
161
|
): Result.Result<RunState, StateCorrupt> {
|
|
162
|
+
if (
|
|
163
|
+
json !== null &&
|
|
164
|
+
typeof json === "object" &&
|
|
165
|
+
!Array.isArray(json) &&
|
|
166
|
+
"pending_floating_exit" in json &&
|
|
167
|
+
json.pending_floating_exit !== null
|
|
168
|
+
) {
|
|
169
|
+
return Result.fail(
|
|
170
|
+
new StateCorrupt({
|
|
171
|
+
path,
|
|
172
|
+
message:
|
|
173
|
+
'this run has an active legacy floating dispatch, but floating dispatch was removed; dismiss or terminate the old popup first, then run `apnea abandon` and `apnea start "<goal>"`',
|
|
174
|
+
}),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const raw =
|
|
179
|
+
json !== null && typeof json === "object" && !Array.isArray(json)
|
|
180
|
+
? (json as Record<string, unknown>)
|
|
181
|
+
: {}
|
|
182
|
+
const hasRequiredRework = raw.required_rework !== undefined
|
|
183
|
+
const hasPendingDelivery = raw.pending_delivery !== undefined
|
|
184
|
+
const hasPendingCommit =
|
|
185
|
+
"pending_commit" in raw && raw.pending_commit !== undefined
|
|
72
186
|
const decoded = Schema.decodeUnknownResult(RunStateSchema)(json)
|
|
73
187
|
if (Result.isFailure(decoded)) {
|
|
74
188
|
return Result.fail(
|
|
@@ -79,9 +193,80 @@ export function decodeRunState(
|
|
|
79
193
|
)
|
|
80
194
|
}
|
|
81
195
|
const d = decoded.success
|
|
196
|
+
// A version-1 writer never emits `pending_commit`; its presence means the
|
|
197
|
+
// file was mixed across versions. Fail closed instead of guessing.
|
|
198
|
+
if (d.version === 1 && hasPendingCommit) {
|
|
199
|
+
return Result.fail(
|
|
200
|
+
new StateCorrupt({
|
|
201
|
+
path,
|
|
202
|
+
message:
|
|
203
|
+
"pending_commit requires state version 2; refusing version-1 file that carries it",
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
if (d.version === 2 && !hasPendingCommit) {
|
|
208
|
+
return Result.fail(
|
|
209
|
+
new StateCorrupt({
|
|
210
|
+
path,
|
|
211
|
+
message:
|
|
212
|
+
"version-2 state must record pending_commit explicitly; refusing file that omits it",
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
d.pending_commit !== null &&
|
|
218
|
+
d.pending_commit !== undefined &&
|
|
219
|
+
d.step !== "committing"
|
|
220
|
+
) {
|
|
221
|
+
return Result.fail(
|
|
222
|
+
new StateCorrupt({
|
|
223
|
+
path,
|
|
224
|
+
message: `pending_commit requires step "committing", found "${d.step}"`,
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
d.pending_commit != null &&
|
|
230
|
+
d.pending_commit.phase_index !== d.phase_index
|
|
231
|
+
) {
|
|
232
|
+
return Result.fail(
|
|
233
|
+
new StateCorrupt({
|
|
234
|
+
path,
|
|
235
|
+
message: `pending_commit targets phase ${d.pending_commit.phase_index} but state is at phase ${d.phase_index}`,
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
if (
|
|
240
|
+
d.pending_commit != null &&
|
|
241
|
+
!isPersistedArtifactPath(d.pending_commit.verify_log)
|
|
242
|
+
) {
|
|
243
|
+
return Result.fail(
|
|
244
|
+
new StateCorrupt({
|
|
245
|
+
path,
|
|
246
|
+
message:
|
|
247
|
+
"pending_commit.verify_log must be a repository-relative .apnea/ path",
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
for (const [field, value] of [
|
|
252
|
+
["pending_artifact", d.pending_artifact],
|
|
253
|
+
["current_phase_package", d.current_phase_package],
|
|
254
|
+
["current_code_review", d.current_code_review],
|
|
255
|
+
] as const) {
|
|
256
|
+
if (value !== null && !isPersistedArtifactPath(value)) {
|
|
257
|
+
return Result.fail(
|
|
258
|
+
new StateCorrupt({
|
|
259
|
+
path,
|
|
260
|
+
message: `${field} must be a repository-relative .apnea/ path`,
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
82
265
|
// Backward-compat defaults for state.json files predating pane tracking.
|
|
266
|
+
// Every decoded state migrates to version 2; the rewrite to disk happens
|
|
267
|
+
// at the next normal save.
|
|
83
268
|
const state: RunState = {
|
|
84
|
-
version:
|
|
269
|
+
version: 2,
|
|
85
270
|
slug: d.slug,
|
|
86
271
|
step: d.step as Step,
|
|
87
272
|
phase_index: d.phase_index,
|
|
@@ -93,9 +278,13 @@ export function decodeRunState(
|
|
|
93
278
|
last_error: d.last_error,
|
|
94
279
|
pending_artifact: d.pending_artifact,
|
|
95
280
|
pending_role: d.pending_role,
|
|
281
|
+
pending_delivery: hasPendingDelivery
|
|
282
|
+
? (d.pending_delivery ?? null)
|
|
283
|
+
: d.pending_artifact !== null && d.pending_pane_id != null
|
|
284
|
+
? "interactive"
|
|
285
|
+
: null,
|
|
96
286
|
pending_pane_id: d.pending_pane_id ?? null,
|
|
97
287
|
pending_pane_label: d.pending_pane_label ?? null,
|
|
98
|
-
pending_floating_exit: d.pending_floating_exit ?? null,
|
|
99
288
|
pending_started_at: d.pending_started_at ?? null,
|
|
100
289
|
pending_deadline_ms: d.pending_deadline_ms ?? null,
|
|
101
290
|
pending_nudged_at: d.pending_nudged_at ?? null,
|
|
@@ -111,7 +300,28 @@ export function decodeRunState(
|
|
|
111
300
|
reviewer_tree_fingerprint: d.reviewer_tree_fingerprint,
|
|
112
301
|
current_phase_package: d.current_phase_package,
|
|
113
302
|
current_code_review: d.current_code_review,
|
|
114
|
-
|
|
303
|
+
required_rework: (hasRequiredRework
|
|
304
|
+
? d.required_rework
|
|
305
|
+
: raw.phase_package_rework === true
|
|
306
|
+
? "phase_package"
|
|
307
|
+
: null) as RequiredReworkTarget | null,
|
|
308
|
+
pending_commit: d.pending_commit ?? null,
|
|
309
|
+
}
|
|
310
|
+
if (
|
|
311
|
+
!hasRequiredRework &&
|
|
312
|
+
state.required_rework === null &&
|
|
313
|
+
d.step === "planning"
|
|
314
|
+
) {
|
|
315
|
+
Object.defineProperty(state, LEGACY_PLAN_REWORK, { value: true })
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
!hasRequiredRework &&
|
|
319
|
+
state.required_rework === null &&
|
|
320
|
+
d.step === "coding" &&
|
|
321
|
+
d.current_code_review !== null &&
|
|
322
|
+
!hasMatchingPendingCoderDispatch(d)
|
|
323
|
+
) {
|
|
324
|
+
Object.defineProperty(state, LEGACY_CODE_REWORK, { value: true })
|
|
115
325
|
}
|
|
116
326
|
return Result.succeed(state)
|
|
117
327
|
}
|
|
@@ -5,6 +5,7 @@ import { FileSystemLive } from "./file-system.ts"
|
|
|
5
5
|
import { makeHerdrLive } from "./herdr.ts"
|
|
6
6
|
import { RunStoreLive } from "./run-store.ts"
|
|
7
7
|
import { VcsLive } from "./vcs.ts"
|
|
8
|
+
import { ProcessLive } from "./process.ts"
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Blueprint layer for tool calls. Built freshly on every `Effect.provide`
|
|
@@ -18,7 +19,7 @@ export const makeAppLive = (hostAdapter: ApneaHostAdapter) =>
|
|
|
18
19
|
VcsLive,
|
|
19
20
|
makeHerdrLive(hostAdapter),
|
|
20
21
|
),
|
|
21
|
-
FileSystemLive,
|
|
22
|
+
Layer.merge(FileSystemLive, ProcessLive),
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
export const AppLive = makeAppLive(neutralHostAdapter)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as os from "node:os"
|
|
1
2
|
import { Context, Effect, Layer, Result } from "effect"
|
|
2
3
|
import { globalConfigPath, projectConfigPath } from "../domain/paths.ts"
|
|
3
4
|
import { ConfigError } from "../errors.ts"
|
|
@@ -50,7 +51,8 @@ export const ConfigLive = Layer.effect(
|
|
|
50
51
|
|
|
51
52
|
const load = (root: string): Effect.Effect<ApneaConfig, ConfigError> =>
|
|
52
53
|
Effect.gen(function* () {
|
|
53
|
-
const
|
|
54
|
+
const trustedHome = os.homedir()
|
|
55
|
+
const gPath = globalConfigPath(trustedHome)
|
|
54
56
|
const gPresent = yield* fs.exists(gPath)
|
|
55
57
|
if (!gPresent) {
|
|
56
58
|
return yield* new ConfigError({
|
|
@@ -58,7 +60,7 @@ export const ConfigLive = Layer.effect(
|
|
|
58
60
|
path: gPath,
|
|
59
61
|
})
|
|
60
62
|
}
|
|
61
|
-
const gText = yield* fs.
|
|
63
|
+
const gText = yield* fs.readTrustedGlobalFile(trustedHome, gPath)
|
|
62
64
|
const gRaw = yield* parseJson(gText, gPath)
|
|
63
65
|
const gDecoded = decodeGlobalConfig(gRaw)
|
|
64
66
|
if (Result.isFailure(gDecoded)) {
|
|
@@ -67,9 +69,9 @@ export const ConfigLive = Layer.effect(
|
|
|
67
69
|
let cfg = gDecoded.success
|
|
68
70
|
|
|
69
71
|
const pPath = projectConfigPath(root)
|
|
70
|
-
const pPresent = yield* fs.
|
|
72
|
+
const pPresent = yield* fs.projectPathExists(root, pPath)
|
|
71
73
|
if (pPresent) {
|
|
72
|
-
const pText = yield* fs.
|
|
74
|
+
const pText = yield* fs.readProjectFile(root, pPath)
|
|
73
75
|
const pRaw = yield* parseJson(pText, pPath)
|
|
74
76
|
const pDecoded = decodeProjectConfig(pRaw)
|
|
75
77
|
if (Result.isFailure(pDecoded)) {
|