@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
|
@@ -1,128 +1,220 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
return run !== null && run[1]!.length % 2 === 1
|
|
1
|
+
export type VerifyBlock = {
|
|
2
|
+
readonly interpreter: "bash" | "sh"
|
|
3
|
+
readonly source: string
|
|
5
4
|
}
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* an odd run is dropped, the rest stay (they are escaped literals). A
|
|
25
|
-
* backslash that is not the line's last character continues nothing —
|
|
26
|
-
* `echo hi \ ` escapes the space and ends the command.
|
|
27
|
-
* - A `#` at the start of a logical command is a comment; its own trailing
|
|
28
|
-
* backslash is inside the comment and continues nothing.
|
|
29
|
-
* - A comment line reached MID-continuation ends the logical command, when the
|
|
30
|
-
* `#` would start a word in the joined text (whitespace before it). Without
|
|
31
|
-
* whitespace on either side of the join, `over\` + `#note` is the single
|
|
32
|
-
* word `over#note`, not a comment — so it is appended, not terminated on.
|
|
33
|
-
*/
|
|
34
|
-
function logicalCommands(lines: string[]): string[] {
|
|
35
|
-
const out: string[] = []
|
|
36
|
-
let acc = ""
|
|
37
|
-
let pending = false
|
|
38
|
-
for (const line of lines) {
|
|
39
|
-
const isCommentish = line.trim().startsWith("#")
|
|
40
|
-
if (!pending && isCommentish) continue
|
|
41
|
-
if (pending && isCommentish && (/\s$/.test(acc) || /^\s/.test(line))) {
|
|
42
|
-
out.push(acc)
|
|
43
|
-
acc = ""
|
|
44
|
-
pending = false
|
|
45
|
-
continue
|
|
46
|
-
}
|
|
47
|
-
const continues = endsInContinuation(line)
|
|
48
|
-
const text = continues ? line.slice(0, -1) : line
|
|
49
|
-
acc = pending ? acc + text : text
|
|
50
|
-
pending = continues
|
|
51
|
-
if (!pending) {
|
|
52
|
-
out.push(acc)
|
|
53
|
-
acc = ""
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
// A dangling continuation on the last line: keep what we have rather than
|
|
57
|
-
// dropping the command on the floor.
|
|
58
|
-
if (pending) out.push(acc)
|
|
59
|
-
return out
|
|
6
|
+
type FenceRegion = {
|
|
7
|
+
readonly start: number
|
|
8
|
+
readonly bodyStart: number
|
|
9
|
+
readonly bodyEnd: number
|
|
10
|
+
readonly info: string
|
|
11
|
+
readonly indent: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Heading = {
|
|
15
|
+
readonly start: number
|
|
16
|
+
readonly end: number
|
|
17
|
+
readonly rank: number | null
|
|
18
|
+
readonly text: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeVerifySource(source: string): string {
|
|
22
|
+
return source.replace(/\r\n?/g, "\n")
|
|
60
23
|
}
|
|
61
24
|
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
25
|
+
function atxHeading(line: string): { rank: number; text: string } | null {
|
|
26
|
+
const match = /^ {0,3}(#{1,6})(?:[\t ]+(.*?)|[\t ]*)$/.exec(line)
|
|
27
|
+
if (!match) return null
|
|
28
|
+
return {
|
|
29
|
+
rank: match[1]!.length,
|
|
30
|
+
text: (match[2] ?? "").replace(/[\t ]+#+[\t ]*$/, "").trim(),
|
|
67
31
|
}
|
|
68
|
-
return cmds
|
|
69
32
|
}
|
|
70
33
|
|
|
71
|
-
function
|
|
72
|
-
return
|
|
34
|
+
function boldHeading(line: string): string | null {
|
|
35
|
+
return /^ {0,3}\*\*(.+?)\*\*[\t ]*$/.exec(line)?.[1]?.trim() ?? null
|
|
73
36
|
}
|
|
74
37
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
38
|
+
function scanMarkdown(text: string): {
|
|
39
|
+
fences: FenceRegion[]
|
|
40
|
+
headings: Heading[]
|
|
41
|
+
unclosedFence: boolean
|
|
42
|
+
} {
|
|
43
|
+
const fences: FenceRegion[] = []
|
|
44
|
+
const headings: Heading[] = []
|
|
45
|
+
let open:
|
|
46
|
+
| {
|
|
47
|
+
marker: "`" | "~"
|
|
48
|
+
length: number
|
|
49
|
+
start: number
|
|
50
|
+
bodyStart: number
|
|
51
|
+
info: string
|
|
52
|
+
indent: number
|
|
53
|
+
}
|
|
54
|
+
| undefined
|
|
55
|
+
|
|
56
|
+
let offset = 0
|
|
57
|
+
while (offset < text.length) {
|
|
58
|
+
const newline = text.indexOf("\n", offset)
|
|
59
|
+
const lineEnd = newline === -1 ? text.length : newline
|
|
60
|
+
const next = newline === -1 ? text.length : newline + 1
|
|
61
|
+
const line = text.slice(offset, lineEnd)
|
|
62
|
+
|
|
63
|
+
if (open) {
|
|
64
|
+
const closing = /^ {0,3}(`{3,}|~{3,})[\t ]*$/.exec(line)?.[1]
|
|
65
|
+
if (closing?.[0] === open.marker && closing.length >= open.length) {
|
|
66
|
+
fences.push({
|
|
67
|
+
start: open.start,
|
|
68
|
+
bodyStart: open.bodyStart,
|
|
69
|
+
bodyEnd: offset,
|
|
70
|
+
info: open.info,
|
|
71
|
+
indent: open.indent,
|
|
72
|
+
})
|
|
73
|
+
open = undefined
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
const opening = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line)
|
|
77
|
+
const marker = opening?.[2]
|
|
78
|
+
const info = opening?.[3] ?? ""
|
|
79
|
+
if (marker && !(marker[0] === "`" && info.includes("`"))) {
|
|
80
|
+
open = {
|
|
81
|
+
marker: marker[0] as "`" | "~",
|
|
82
|
+
length: marker.length,
|
|
83
|
+
start: offset,
|
|
84
|
+
bodyStart: next,
|
|
85
|
+
info: info.trim(),
|
|
86
|
+
indent: opening[1]!.length,
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const atx = atxHeading(line)
|
|
90
|
+
const bold = boldHeading(line)
|
|
91
|
+
if (atx) {
|
|
92
|
+
headings.push({
|
|
93
|
+
start: offset,
|
|
94
|
+
end: next,
|
|
95
|
+
rank: atx.rank,
|
|
96
|
+
text: atx.text,
|
|
97
|
+
})
|
|
98
|
+
} else if (bold !== null) {
|
|
99
|
+
headings.push({ start: offset, end: next, rank: null, text: bold })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
90
102
|
}
|
|
103
|
+
|
|
104
|
+
offset = next
|
|
91
105
|
}
|
|
92
106
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
return { fences, headings, unclosedFence: open !== undefined }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toVerifyBlock(text: string, fence: FenceRegion): VerifyBlock | null {
|
|
111
|
+
const language = fence.info.toLowerCase()
|
|
112
|
+
if (language !== "bash" && language !== "sh" && language !== "shell") {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const body = text.slice(fence.bodyStart, fence.bodyEnd)
|
|
117
|
+
const source =
|
|
118
|
+
fence.indent === 0
|
|
119
|
+
? body
|
|
120
|
+
: body.replace(new RegExp(`^ {0,${fence.indent}}`, "gm"), "")
|
|
121
|
+
const hasExecutableLine = source
|
|
122
|
+
.split("\n")
|
|
123
|
+
.some((line) => line.trim() !== "" && !line.trimStart().startsWith("#"))
|
|
124
|
+
if (!hasExecutableLine) return null
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
interpreter: language === "sh" ? "sh" : "bash",
|
|
128
|
+
source,
|
|
100
129
|
}
|
|
130
|
+
}
|
|
101
131
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
function isShellFence(fence: FenceRegion): boolean {
|
|
133
|
+
return /^(?:bash|sh|shell)$/i.test(fence.info)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Does this line end in an odd run of backslashes? */
|
|
137
|
+
function endsInContinuation(line: string): boolean {
|
|
138
|
+
const run = /(\\+)$/.exec(line)
|
|
139
|
+
return run !== null && run[1]!.length % 2 === 1
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractLegacyBlocks(text: string): VerifyBlock[] {
|
|
143
|
+
const blocks: VerifyBlock[] = []
|
|
144
|
+
const rawLines = text.split("\n")
|
|
113
145
|
for (let i = 0; i < rawLines.length; i++) {
|
|
114
|
-
const
|
|
146
|
+
const match = rawLines[i]!.match(
|
|
115
147
|
/^\s*(?:\$\s+)?((?:test |node |npm |bun |bunx |chmod |head ).+)$/,
|
|
116
148
|
)
|
|
117
|
-
if (!
|
|
118
|
-
let
|
|
119
|
-
while (endsInContinuation(
|
|
120
|
-
|
|
149
|
+
if (!match) continue
|
|
150
|
+
let source = match[1]!
|
|
151
|
+
while (endsInContinuation(source) && i + 1 < rawLines.length) {
|
|
152
|
+
source = source.slice(0, -1) + rawLines[++i]!
|
|
121
153
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (endsInContinuation(cmd)) cmd = cmd.slice(0, -1)
|
|
125
|
-
cmds.push(cmd.trim())
|
|
154
|
+
if (endsInContinuation(source)) source = source.slice(0, -1)
|
|
155
|
+
blocks.push({ interpreter: "bash", source: source.trim() })
|
|
126
156
|
}
|
|
127
|
-
return
|
|
157
|
+
return blocks
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract interpreter-aware verification scripts from a phase package.
|
|
162
|
+
* A Verify commands section owns every shell fence below it. Without that
|
|
163
|
+
* section, only the last shell fence is verification, matching legacy package
|
|
164
|
+
* selection without splitting a script into unrelated command lines.
|
|
165
|
+
*/
|
|
166
|
+
export function extractVerifyBlocks(phasePackageText: string): VerifyBlock[] {
|
|
167
|
+
const text = normalizeVerifySource(phasePackageText)
|
|
168
|
+
const { fences, headings, unclosedFence } = scanMarkdown(text)
|
|
169
|
+
if (unclosedFence) return []
|
|
170
|
+
const verifyHeading = headings.find(
|
|
171
|
+
(heading) => heading.text.toLowerCase() === "verify commands",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if (verifyHeading) {
|
|
175
|
+
const nextHeading = headings.find(
|
|
176
|
+
(heading) =>
|
|
177
|
+
heading.start >= verifyHeading.end &&
|
|
178
|
+
(heading.rank === null ||
|
|
179
|
+
verifyHeading.rank === null ||
|
|
180
|
+
(heading.rank !== null && heading.rank <= verifyHeading.rank)),
|
|
181
|
+
)
|
|
182
|
+
const sectionEnd = nextHeading?.start ?? text.length
|
|
183
|
+
const sectionFences = fences.filter(
|
|
184
|
+
(fence) => fence.start >= verifyHeading.end && fence.start < sectionEnd,
|
|
185
|
+
)
|
|
186
|
+
const blocks = sectionFences
|
|
187
|
+
.filter(isShellFence)
|
|
188
|
+
.map((fence) => toVerifyBlock(text, fence))
|
|
189
|
+
.filter((block): block is VerifyBlock => block !== null)
|
|
190
|
+
return sectionFences.length > 0
|
|
191
|
+
? blocks
|
|
192
|
+
: extractLegacyBlocks(text.slice(verifyHeading.end, sectionEnd))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const shellFences = fences.filter(isShellFence)
|
|
196
|
+
if (shellFences.length > 0) {
|
|
197
|
+
const block = toVerifyBlock(text, shellFences[shellFences.length - 1]!)
|
|
198
|
+
return block ? [block] : []
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return fences.length === 0 ? extractLegacyBlocks(text) : []
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Render a block for readable logs without leaking its temp path. */
|
|
205
|
+
export function formatVerifyBlock(block: VerifyBlock): string {
|
|
206
|
+
const source = normalizeVerifySource(block.source)
|
|
207
|
+
const body = source.endsWith("\n") ? source.slice(0, -1) : source
|
|
208
|
+
const displayedSource = body
|
|
209
|
+
.split("\n")
|
|
210
|
+
.map((line) => `| ${line}`)
|
|
211
|
+
.join("\n")
|
|
212
|
+
return `${block.interpreter} -e [verification block]\n${displayedSource}`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Render a verification block as a runnable command without its temp path. */
|
|
216
|
+
export function formatVerifyCommand(block: VerifyBlock): string {
|
|
217
|
+
const source = normalizeVerifySource(block.source)
|
|
218
|
+
const quotedSource = `'${source.replaceAll("'", `'"'"'`)}'`
|
|
219
|
+
return `${block.interpreter} -e -c ${quotedSource}`
|
|
128
220
|
}
|
package/extension/errors.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { Schema } from "effect"
|
|
|
2
2
|
import { err, type ToolErr } from "./result.ts"
|
|
3
3
|
|
|
4
4
|
/** No `.apnea/state.json` for the current project root. */
|
|
5
|
-
export class NoRunState extends Schema.
|
|
5
|
+
export class NoRunState extends Schema.TaggedError<NoRunState>()(
|
|
6
6
|
"NoRunState",
|
|
7
7
|
{},
|
|
8
8
|
) {}
|
|
9
9
|
|
|
10
10
|
/** Tool call refused by the step → legal-tools table. */
|
|
11
|
-
export class IllegalTool extends Schema.
|
|
11
|
+
export class IllegalTool extends Schema.TaggedError<IllegalTool>()(
|
|
12
12
|
"IllegalTool",
|
|
13
13
|
{
|
|
14
14
|
step: Schema.String,
|
|
@@ -18,7 +18,7 @@ export class IllegalTool extends Schema.TaggedErrorClass<IllegalTool>()(
|
|
|
18
18
|
) {}
|
|
19
19
|
|
|
20
20
|
/** Dispatch kind refused at the current step. */
|
|
21
|
-
export class IllegalKind extends Schema.
|
|
21
|
+
export class IllegalKind extends Schema.TaggedError<IllegalKind>()(
|
|
22
22
|
"IllegalKind",
|
|
23
23
|
{
|
|
24
24
|
step: Schema.String,
|
|
@@ -28,7 +28,7 @@ export class IllegalKind extends Schema.TaggedErrorClass<IllegalKind>()(
|
|
|
28
28
|
) {}
|
|
29
29
|
|
|
30
30
|
/** Global or project config missing, invalid, or untrusted. */
|
|
31
|
-
export class ConfigError extends Schema.
|
|
31
|
+
export class ConfigError extends Schema.TaggedError<ConfigError>()(
|
|
32
32
|
"ConfigError",
|
|
33
33
|
{
|
|
34
34
|
message: Schema.String,
|
|
@@ -38,7 +38,7 @@ export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
|
|
|
38
38
|
) {}
|
|
39
39
|
|
|
40
40
|
/** `state.json` present but not decodable / inconsistent. */
|
|
41
|
-
export class StateCorrupt extends Schema.
|
|
41
|
+
export class StateCorrupt extends Schema.TaggedError<StateCorrupt>()(
|
|
42
42
|
"StateCorrupt",
|
|
43
43
|
{
|
|
44
44
|
path: Schema.String,
|
|
@@ -47,23 +47,32 @@ export class StateCorrupt extends Schema.TaggedErrorClass<StateCorrupt>()(
|
|
|
47
47
|
) {}
|
|
48
48
|
|
|
49
49
|
/** VCS detect / dirty / commit / bookmark failure. */
|
|
50
|
-
export class VcsError extends Schema.
|
|
50
|
+
export class VcsError extends Schema.TaggedError<VcsError>()("VcsError", {
|
|
51
51
|
message: Schema.String,
|
|
52
52
|
command: Schema.optional(Schema.String),
|
|
53
53
|
}) {}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
export class
|
|
57
|
-
"
|
|
55
|
+
/** Another live Apnea process owns the repository mutation lock. */
|
|
56
|
+
export class OperationLocked extends Schema.TaggedError<OperationLocked>()(
|
|
57
|
+
"OperationLocked",
|
|
58
58
|
{
|
|
59
59
|
message: Schema.String,
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
repository: Schema.String,
|
|
61
|
+
lock_path: Schema.String,
|
|
62
|
+
reason: Schema.String,
|
|
63
|
+
pid: Schema.Number,
|
|
62
64
|
},
|
|
63
65
|
) {}
|
|
64
66
|
|
|
67
|
+
/** Herdr CLI or pane failure. */
|
|
68
|
+
export class HerdrError extends Schema.TaggedError<HerdrError>()("HerdrError", {
|
|
69
|
+
message: Schema.String,
|
|
70
|
+
command: Schema.optional(Schema.String),
|
|
71
|
+
details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
72
|
+
}) {}
|
|
73
|
+
|
|
65
74
|
/** Commit/review gate refused (e.g. verdict not APPROVED). */
|
|
66
|
-
export class GateRefused extends Schema.
|
|
75
|
+
export class GateRefused extends Schema.TaggedError<GateRefused>()(
|
|
67
76
|
"GateRefused",
|
|
68
77
|
{
|
|
69
78
|
gate: Schema.String,
|
|
@@ -73,7 +82,7 @@ export class GateRefused extends Schema.TaggedErrorClass<GateRefused>()(
|
|
|
73
82
|
) {}
|
|
74
83
|
|
|
75
84
|
/** `workflow_wait` hit its timeout without a complete artifact. */
|
|
76
|
-
export class WaitTimeout extends Schema.
|
|
85
|
+
export class WaitTimeout extends Schema.TaggedError<WaitTimeout>()(
|
|
77
86
|
"WaitTimeout",
|
|
78
87
|
{
|
|
79
88
|
artifact: Schema.String,
|
|
@@ -83,7 +92,7 @@ export class WaitTimeout extends Schema.TaggedErrorClass<WaitTimeout>()(
|
|
|
83
92
|
) {}
|
|
84
93
|
|
|
85
94
|
/** `workflow_wait` aborted (Esc / cancel signal). */
|
|
86
|
-
export class WaitAborted extends Schema.
|
|
95
|
+
export class WaitAborted extends Schema.TaggedError<WaitAborted>()(
|
|
87
96
|
"WaitAborted",
|
|
88
97
|
{
|
|
89
98
|
artifact: Schema.String,
|
|
@@ -91,8 +100,17 @@ export class WaitAborted extends Schema.TaggedErrorClass<WaitAborted>()(
|
|
|
91
100
|
},
|
|
92
101
|
) {}
|
|
93
102
|
|
|
103
|
+
/** A non-wait operation was interrupted by its host cancellation signal. */
|
|
104
|
+
export class OperationAborted extends Schema.TaggedError<OperationAborted>()(
|
|
105
|
+
"OperationAborted",
|
|
106
|
+
{
|
|
107
|
+
operation: Schema.String,
|
|
108
|
+
details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
109
|
+
},
|
|
110
|
+
) {}
|
|
111
|
+
|
|
94
112
|
/** Artifact exists but front-matter / shape is invalid. */
|
|
95
|
-
export class ArtifactInvalid extends Schema.
|
|
113
|
+
export class ArtifactInvalid extends Schema.TaggedError<ArtifactInvalid>()(
|
|
96
114
|
"ArtifactInvalid",
|
|
97
115
|
{
|
|
98
116
|
artifact: Schema.String,
|
|
@@ -101,7 +119,7 @@ export class ArtifactInvalid extends Schema.TaggedErrorClass<ArtifactInvalid>()(
|
|
|
101
119
|
) {}
|
|
102
120
|
|
|
103
121
|
/** Phase-package verify commands failed — commit refused. */
|
|
104
|
-
export class VerifyFailed extends Schema.
|
|
122
|
+
export class VerifyFailed extends Schema.TaggedError<VerifyFailed>()(
|
|
105
123
|
"VerifyFailed",
|
|
106
124
|
{
|
|
107
125
|
commands: Schema.Array(Schema.String),
|
|
@@ -119,10 +137,12 @@ export type AppError =
|
|
|
119
137
|
| ConfigError
|
|
120
138
|
| StateCorrupt
|
|
121
139
|
| VcsError
|
|
140
|
+
| OperationLocked
|
|
122
141
|
| HerdrError
|
|
123
142
|
| GateRefused
|
|
124
143
|
| WaitTimeout
|
|
125
144
|
| WaitAborted
|
|
145
|
+
| OperationAborted
|
|
126
146
|
| ArtifactInvalid
|
|
127
147
|
| VerifyFailed
|
|
128
148
|
|
|
@@ -133,10 +153,12 @@ const APP_ERROR_TAG_LIST = [
|
|
|
133
153
|
"ConfigError",
|
|
134
154
|
"StateCorrupt",
|
|
135
155
|
"VcsError",
|
|
156
|
+
"OperationLocked",
|
|
136
157
|
"HerdrError",
|
|
137
158
|
"GateRefused",
|
|
138
159
|
"WaitTimeout",
|
|
139
160
|
"WaitAborted",
|
|
161
|
+
"OperationAborted",
|
|
140
162
|
"ArtifactInvalid",
|
|
141
163
|
"VerifyFailed",
|
|
142
164
|
] as const satisfies readonly AppError["_tag"][]
|
|
@@ -204,6 +226,15 @@ export function toToolResult(e: AppError): ToolErr {
|
|
|
204
226
|
return err(e.message, {
|
|
205
227
|
data: e.command !== undefined ? { command: e.command } : undefined,
|
|
206
228
|
})
|
|
229
|
+
case "OperationLocked":
|
|
230
|
+
return err(e.message, {
|
|
231
|
+
data: {
|
|
232
|
+
repository: e.repository,
|
|
233
|
+
lock_path: e.lock_path,
|
|
234
|
+
reason: e.reason,
|
|
235
|
+
pid: e.pid,
|
|
236
|
+
},
|
|
237
|
+
})
|
|
207
238
|
case "HerdrError":
|
|
208
239
|
return err(e.message, {
|
|
209
240
|
data:
|
|
@@ -233,6 +264,10 @@ export function toToolResult(e: AppError): ToolErr {
|
|
|
233
264
|
return err("workflow_wait aborted (Esc / cancel)", {
|
|
234
265
|
data: { artifact: e.artifact, ...(e.details ?? {}) },
|
|
235
266
|
})
|
|
267
|
+
case "OperationAborted":
|
|
268
|
+
return err(`${e.operation} aborted (signal / cancel)`, {
|
|
269
|
+
data: { operation: e.operation, ...(e.details ?? {}) },
|
|
270
|
+
})
|
|
236
271
|
case "ArtifactInvalid":
|
|
237
272
|
return err(e.message, { data: { artifact: e.artifact } })
|
|
238
273
|
case "VerifyFailed":
|
package/extension/registry.ts
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
MAX_AUTO_POLL_MS,
|
|
19
19
|
MIN_POLL_MS,
|
|
20
20
|
type WaitParams,
|
|
21
|
-
type WaitHooks,
|
|
22
21
|
} from "./workflows/wait.ts"
|
|
22
|
+
import type { OperationHooks } from "./operation-hooks.ts"
|
|
23
23
|
|
|
24
24
|
export type Operation = {
|
|
25
25
|
/** Pi tool name, or null when the operation is not model-facing. */
|
|
@@ -46,14 +46,14 @@ export type Operation = {
|
|
|
46
46
|
type RegisteredOperation = Operation & {
|
|
47
47
|
readonly run: (
|
|
48
48
|
params: Record<string, unknown>,
|
|
49
|
-
hooks?:
|
|
49
|
+
hooks?: OperationHooks,
|
|
50
50
|
) => Promise<ToolResult>
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export type ExecuteOperation = (
|
|
54
54
|
verb: string,
|
|
55
55
|
params: Record<string, unknown>,
|
|
56
|
-
hooks?:
|
|
56
|
+
hooks?: OperationHooks,
|
|
57
57
|
) => Promise<ToolResult>
|
|
58
58
|
|
|
59
59
|
// Sourced from domain/state-machine.ts (not hardcoded here) so a new kind
|
|
@@ -86,8 +86,8 @@ function createRegisteredOperations(
|
|
|
86
86
|
force: Type.Optional(Type.Boolean()),
|
|
87
87
|
agents_md: Type.Optional(Type.Boolean()),
|
|
88
88
|
}),
|
|
89
|
-
run: (p) =>
|
|
90
|
-
apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter),
|
|
89
|
+
run: (p, hooks) =>
|
|
90
|
+
apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter, hooks),
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
tool: "workflow_start",
|
|
@@ -115,7 +115,7 @@ function createRegisteredOperations(
|
|
|
115
115
|
// Mirrors the guard in index.ts's execute(): without it, action=start
|
|
116
116
|
// with no goal reaches slugify(undefined) in workflows/start.ts and
|
|
117
117
|
// throws instead of returning a clean refusal.
|
|
118
|
-
run: (p) => {
|
|
118
|
+
run: (p, hooks) => {
|
|
119
119
|
const params = p as Parameters<typeof workflowStart>[0]
|
|
120
120
|
const action = params.action ?? "start"
|
|
121
121
|
if (action === "start" && !params.goal?.trim()) {
|
|
@@ -132,16 +132,17 @@ function createRegisteredOperations(
|
|
|
132
132
|
action,
|
|
133
133
|
},
|
|
134
134
|
hostAdapter,
|
|
135
|
+
hooks,
|
|
135
136
|
)
|
|
136
137
|
},
|
|
137
138
|
},
|
|
138
139
|
{
|
|
139
140
|
tool: "dispatch_role",
|
|
140
141
|
verb: "dispatch",
|
|
141
|
-
usage: "<kind> [--rework]",
|
|
142
|
+
usage: "<kind> [--rework] [--redeliver]",
|
|
142
143
|
summary: "Write the task file and launch a role in a Herdr pane.",
|
|
143
144
|
guidance:
|
|
144
|
-
"One outstanding dispatch at a time.
|
|
145
|
+
"One outstanding dispatch at a time. Persisted review state selects rework and advances its round. rework=true is a deprecated assertion through 0.2.x and grants authority only for ambiguous version-1 plan or code migration. Use redeliver=true only to reuse matching pending ownership after proving the prior delivery is dead. A complete pending artifact refuses redelivery; call workflow_wait to ingest it.",
|
|
145
146
|
params: operationParams({
|
|
146
147
|
kind: DispatchKind,
|
|
147
148
|
task_markdown: Type.Optional(
|
|
@@ -149,14 +150,22 @@ function createRegisteredOperations(
|
|
|
149
150
|
),
|
|
150
151
|
rework: Type.Optional(
|
|
151
152
|
Type.Boolean({
|
|
152
|
-
description:
|
|
153
|
+
description:
|
|
154
|
+
"Deprecated assertion through 0.2.x; persisted state owns rework",
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
redeliver: Type.Optional(
|
|
158
|
+
Type.Boolean({
|
|
159
|
+
description:
|
|
160
|
+
"Reuse matching pending ownership after the prior delivery is demonstrably dead",
|
|
153
161
|
}),
|
|
154
162
|
),
|
|
155
163
|
}),
|
|
156
|
-
run: (p) =>
|
|
164
|
+
run: (p, hooks) =>
|
|
157
165
|
workflowDispatch(
|
|
158
166
|
p as Parameters<typeof workflowDispatch>[0],
|
|
159
167
|
hostAdapter,
|
|
168
|
+
hooks,
|
|
160
169
|
),
|
|
161
170
|
},
|
|
162
171
|
{
|
|
@@ -180,8 +189,9 @@ function createRegisteredOperations(
|
|
|
180
189
|
// moves, and then the schema promises a budget the runtime refuses.
|
|
181
190
|
params: operationParams({
|
|
182
191
|
poll_ms: Type.Optional(
|
|
183
|
-
Type.
|
|
192
|
+
Type.Integer({
|
|
184
193
|
minimum: MIN_POLL_MS,
|
|
194
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
185
195
|
description:
|
|
186
196
|
`Milliseconds between polls. At least ${MIN_POLL_MS} — each poll spawns two herdr subprocesses. ` +
|
|
187
197
|
`Keep it at or under ${MAX_AUTO_POLL_MS} unless you also pass budget_ms: above that, the floor ` +
|
|
@@ -189,7 +199,9 @@ function createRegisteredOperations(
|
|
|
189
199
|
}),
|
|
190
200
|
),
|
|
191
201
|
budget_ms: Type.Optional(
|
|
192
|
-
Type.
|
|
202
|
+
Type.Integer({
|
|
203
|
+
minimum: 1,
|
|
204
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
193
205
|
description:
|
|
194
206
|
`How long THIS call may block — not the role's deadline, which comes from config. ` +
|
|
195
207
|
`Must be at least ${GRACE_MS} + max(${IDLE_NUDGE_AFTER_MS}, ${DEAD_POLLS_NEEDED} x poll_ms), so the call ` +
|
|
@@ -223,10 +235,11 @@ function createRegisteredOperations(
|
|
|
223
235
|
}),
|
|
224
236
|
),
|
|
225
237
|
}),
|
|
226
|
-
run: (p) =>
|
|
238
|
+
run: (p, hooks) =>
|
|
227
239
|
workflowCommitPhase(
|
|
228
240
|
p as Parameters<typeof workflowCommitPhase>[0],
|
|
229
241
|
hostAdapter,
|
|
242
|
+
hooks,
|
|
230
243
|
),
|
|
231
244
|
},
|
|
232
245
|
{
|
|
@@ -236,7 +249,7 @@ function createRegisteredOperations(
|
|
|
236
249
|
summary: "Read-only snapshot of run state and legal next calls.",
|
|
237
250
|
guidance: "Never mutates. Safe to call at any point.",
|
|
238
251
|
params: operationParams({}),
|
|
239
|
-
run: () => workflowStatus(hostAdapter),
|
|
252
|
+
run: (_p, hooks) => workflowStatus(hostAdapter, hooks),
|
|
240
253
|
},
|
|
241
254
|
{
|
|
242
255
|
tool: null,
|
|
@@ -254,10 +267,11 @@ function createRegisteredOperations(
|
|
|
254
267
|
description: "Round key, e.g. plan_review or phase-01/code_review",
|
|
255
268
|
}),
|
|
256
269
|
}),
|
|
257
|
-
run: (p) =>
|
|
270
|
+
run: (p, hooks) =>
|
|
258
271
|
workflowResetRounds(
|
|
259
272
|
p as Parameters<typeof workflowResetRounds>[0],
|
|
260
273
|
hostAdapter,
|
|
274
|
+
hooks,
|
|
261
275
|
),
|
|
262
276
|
},
|
|
263
277
|
]
|