@dpeek/codeless 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -10
- package/extension/implementer-reporting.js +147 -0
- package/extension/planner.js +12 -2
- package/package.json +2 -2
- package/spec/workflow.md +39 -12
- package/src/attempt.ts +101 -0
- package/src/cli.ts +219 -22
- package/src/metrics.ts +58 -1
package/README.md
CHANGED
|
@@ -154,9 +154,18 @@ planners or worktree shells are running.
|
|
|
154
154
|
worktree/<slug>/ # stream/<slug> branch
|
|
155
155
|
worktree/main/ # example integration checkout location
|
|
156
156
|
.land-lock/ # shared landing owner and recorded integration commit
|
|
157
|
-
metrics/<slug>/NNN.json #
|
|
157
|
+
metrics/<slug>/NNN.json # dispatch/landing times and deduplicated implementer attempts
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
Run `codeless init` once after configuring a project. It creates the shared state
|
|
161
|
+
layout and the dedicated integration worktree at
|
|
162
|
+
`<workspace>/worktree/<integration-branch>` without requiring Herdr. For the
|
|
163
|
+
default workspace it adds only `/.codeless/state/` to the primary checkout's
|
|
164
|
+
`.gitignore`; an absolute workspace override does not modify repository ignores.
|
|
165
|
+
It is safe to repeat when that exact worktree is registered. It stops rather
|
|
166
|
+
than moving a branch checkout, replacing an occupied target, or broadening a
|
|
167
|
+
repository ignore rule that covers configuration or prompts.
|
|
168
|
+
|
|
160
169
|
Normal `git clean -fd` preserves ignored state. `git clean -fdx` removes ignored
|
|
161
170
|
files and can therefore destroy local Codeless journals, metrics, and worktrees;
|
|
162
171
|
inspect its targets before using it.
|
|
@@ -167,6 +176,7 @@ Run creation, opening, and planner launch from a Herdr-managed shell. Landing
|
|
|
167
176
|
needs no Herdr session.
|
|
168
177
|
|
|
169
178
|
```sh
|
|
179
|
+
codeless init
|
|
170
180
|
codeless create <slug>
|
|
171
181
|
codeless open <slug>
|
|
172
182
|
codeless planner <slug>
|
|
@@ -177,21 +187,35 @@ codeless next <numbered-change-file> <landed-commit>
|
|
|
177
187
|
codeless metrics
|
|
178
188
|
```
|
|
179
189
|
|
|
180
|
-
Slugs are lowercase kebab-case, at most 24 characters. `
|
|
190
|
+
Slugs are lowercase kebab-case, at most 24 characters. `init` validates the
|
|
191
|
+
existing configuration and integration branch, reports the branch, primary
|
|
192
|
+
checkout, workspace, and integration worktree, then creates only the shared
|
|
193
|
+
state directories and canonical integration worktree when absent. All other
|
|
194
|
+
commands validate their prerequisites and never bootstrap this setup. `create` starts
|
|
181
195
|
`stream/<slug>` from the integration branch and creates its local documents;
|
|
182
196
|
it refuses existing streams. `open` resumes a stream. Both run the configured
|
|
183
197
|
install command, then validate and open a planner beside an idle shell. `planner`
|
|
184
198
|
starts Pi in an existing stream's lone shell after the same role preflight. Its
|
|
185
199
|
activation establishes the same identity as creation and reopening.
|
|
186
200
|
Dispatch validates the implementer selection before touching the planner's
|
|
187
|
-
right-hand pane, starts a fresh ephemeral implementer
|
|
201
|
+
right-hand pane, starts a fresh ephemeral implementer with Codeless's reporting
|
|
202
|
+
extension and its explicit Pi extension flag, and waits for completion. Its JSON
|
|
203
|
+
result is a normalized attempt report, which the planner tool exposes before
|
|
204
|
+
queueing review. It includes the actual settled model/thinking selection,
|
|
205
|
+
terminal text and outcome, full-session Pi usage and available Pi cost estimate,
|
|
206
|
+
timestamps, and tool/error counts; prompts, source, thinking, credentials, and
|
|
207
|
+
transcripts are not retained.
|
|
188
208
|
|
|
189
209
|
The first valid dispatch creates one atomic local JSON metric record for its
|
|
190
|
-
stream and numbered change.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
210
|
+
stream and numbered change. Each accepted run receives a new attempt ID and is
|
|
211
|
+
added idempotently under that record; re-ingesting an ID preserves the original
|
|
212
|
+
attempt and retries preserve the original dispatch time. Missing, malformed, or
|
|
213
|
+
unwritable collection data warns and stores an incomplete attempt when possible
|
|
214
|
+
without retrying or failing a settled implementation. After a successful
|
|
215
|
+
integration fast-forward, Codeless records the landed time and commit on that
|
|
216
|
+
change's canonical record, creating a landed record without elapsed time when
|
|
217
|
+
dispatch collection was unavailable; collection warnings never alter dispatch or
|
|
218
|
+
landing.
|
|
195
219
|
`codeless metrics` prints every recorded stream and a project total. Its elapsed
|
|
196
220
|
columns are dispatch-to-land wall-clock time; among landed changes, records
|
|
197
221
|
without a measured duration are explicitly unavailable. Dispatched-but-unlanded
|
|
@@ -233,8 +257,35 @@ From this package directory, run `bun run check` for formatting, lint, types,
|
|
|
233
257
|
and tests, or `bun run test` for tests alone. The integration tests use real Git
|
|
234
258
|
worktrees and mock Herdr/Pi commands; they never launch actual agents.
|
|
235
259
|
|
|
236
|
-
`
|
|
237
|
-
|
|
260
|
+
From a clean `main` checkout, publish the next patch release with:
|
|
261
|
+
|
|
262
|
+
```sh
|
|
263
|
+
bun run release
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The script verifies npm authentication, runs the full check and an npm package
|
|
267
|
+
dry run, increments the patch version, commits `package.json` with the version as
|
|
268
|
+
the complete commit message, creates the matching version tag, and publishes
|
|
269
|
+
`@dpeek/codeless`. It intentionally does not push the commit or tag.
|
|
270
|
+
|
|
271
|
+
For non-interactive local publishing, create an npm granular access token with
|
|
272
|
+
read/write access to `@dpeek/codeless` (or the `@dpeek` scope) and **Bypass 2FA**
|
|
273
|
+
enabled. Put the token in the repository's ignored `.env` file:
|
|
274
|
+
|
|
275
|
+
```sh
|
|
276
|
+
NPM_TOKEN=npm_your_token_here
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Reference that variable from your user-level `~/.npmrc`:
|
|
280
|
+
|
|
281
|
+
```ini
|
|
282
|
+
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`bun run release` loads `.env` and passes the token to npm. Never put the token
|
|
286
|
+
directly in a committed `.npmrc` or source file. If npm package settings disallow
|
|
287
|
+
tokens, publishing will still require an OTP. For hosted CI, prefer npm trusted
|
|
288
|
+
publishing instead of a long-lived token.
|
|
238
289
|
|
|
239
290
|
Keep source, tests, executable, extension, and dependencies inside this project.
|
|
240
291
|
Keep project policies and real prompts outside it. Add automation only for
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
function text(message) {
|
|
4
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) return undefined;
|
|
5
|
+
const value = message.content
|
|
6
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
7
|
+
.map((part) => part.text)
|
|
8
|
+
.join("");
|
|
9
|
+
return value || undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function validUsage(value) {
|
|
13
|
+
return (
|
|
14
|
+
typeof value === "object" &&
|
|
15
|
+
value !== null &&
|
|
16
|
+
["input", "output", "cacheRead", "cacheWrite"].every(
|
|
17
|
+
(key) => Number.isFinite(value[key]) && value[key] >= 0,
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sessionUsage(entries) {
|
|
23
|
+
const usages = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (
|
|
26
|
+
entry?.type === "message" &&
|
|
27
|
+
(entry.message?.role === "assistant" || entry.message?.role === "toolResult") &&
|
|
28
|
+
validUsage(entry.message.usage)
|
|
29
|
+
)
|
|
30
|
+
usages.push(entry.message.usage);
|
|
31
|
+
if (
|
|
32
|
+
(entry?.type === "compaction" || entry?.type === "branch_summary") &&
|
|
33
|
+
validUsage(entry.usage)
|
|
34
|
+
)
|
|
35
|
+
usages.push(entry.usage);
|
|
36
|
+
}
|
|
37
|
+
if (usages.length === 0) return undefined;
|
|
38
|
+
const total = (key) => usages.reduce((sum, usage) => sum + usage[key], 0);
|
|
39
|
+
const costs = usages.map((usage) => usage.cost?.total);
|
|
40
|
+
return {
|
|
41
|
+
input: total("input"),
|
|
42
|
+
output: total("output"),
|
|
43
|
+
cacheRead: total("cacheRead"),
|
|
44
|
+
cacheWrite: total("cacheWrite"),
|
|
45
|
+
...(costs.every((cost) => Number.isFinite(cost) && cost >= 0)
|
|
46
|
+
? {
|
|
47
|
+
cost: {
|
|
48
|
+
amount: costs.reduce((sum, cost) => sum + cost, 0),
|
|
49
|
+
currency: "USD",
|
|
50
|
+
source: "pi-model-estimate",
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
: {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function messages(entries) {
|
|
58
|
+
return entries
|
|
59
|
+
.filter((entry) => entry?.type === "message")
|
|
60
|
+
.map((entry) => entry.message)
|
|
61
|
+
.filter((message) => message?.role === "assistant" || message?.role === "toolResult");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function write(path, value) {
|
|
65
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}`;
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(temporary, `${JSON.stringify(value)}\n`, { flag: "wx" });
|
|
68
|
+
renameSync(temporary, path);
|
|
69
|
+
} finally {
|
|
70
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default function implementerReportingExtension(pi) {
|
|
75
|
+
pi.registerFlag("codeless-attempt", { type: "string" });
|
|
76
|
+
let configuration;
|
|
77
|
+
let startedAt;
|
|
78
|
+
let toolCalls = 0;
|
|
79
|
+
let errorCount = 0;
|
|
80
|
+
pi.on("session_start", () => {
|
|
81
|
+
try {
|
|
82
|
+
const value = JSON.parse(pi.getFlag("codeless-attempt") ?? "");
|
|
83
|
+
if (
|
|
84
|
+
typeof value?.path === "string" &&
|
|
85
|
+
typeof value?.id === "string" &&
|
|
86
|
+
typeof value?.stream === "string" &&
|
|
87
|
+
typeof value?.change === "string"
|
|
88
|
+
)
|
|
89
|
+
configuration = value;
|
|
90
|
+
} catch {}
|
|
91
|
+
});
|
|
92
|
+
pi.on("agent_start", () => {
|
|
93
|
+
startedAt ??= new Date().toISOString();
|
|
94
|
+
});
|
|
95
|
+
pi.on("tool_execution_end", (event) => {
|
|
96
|
+
toolCalls += 1;
|
|
97
|
+
if (event.isError) errorCount += 1;
|
|
98
|
+
});
|
|
99
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
100
|
+
if (configuration === undefined) return;
|
|
101
|
+
const report = configuration;
|
|
102
|
+
configuration = undefined;
|
|
103
|
+
const settledMessages = messages(ctx.sessionManager.getEntries());
|
|
104
|
+
const final = [...settledMessages].reverse().find((message) => message.role === "assistant");
|
|
105
|
+
const totals = sessionUsage(ctx.sessionManager.getEntries());
|
|
106
|
+
const model = final?.responseModel ?? final?.model ?? ctx.model?.id;
|
|
107
|
+
const provider = final?.provider ?? ctx.model?.provider;
|
|
108
|
+
const selection =
|
|
109
|
+
typeof provider === "string" &&
|
|
110
|
+
typeof model === "string" &&
|
|
111
|
+
typeof pi.getThinkingLevel() === "string"
|
|
112
|
+
? { provider, model, thinking: pi.getThinkingLevel() }
|
|
113
|
+
: undefined;
|
|
114
|
+
const finalText = text(final);
|
|
115
|
+
const complete =
|
|
116
|
+
selection !== undefined &&
|
|
117
|
+
typeof final?.stopReason === "string" &&
|
|
118
|
+
final.stopReason.length > 0 &&
|
|
119
|
+
finalText !== undefined &&
|
|
120
|
+
totals !== undefined;
|
|
121
|
+
write(report.path, {
|
|
122
|
+
id: report.id,
|
|
123
|
+
stream: report.stream,
|
|
124
|
+
change: report.change,
|
|
125
|
+
role: "implementer",
|
|
126
|
+
startedAt: startedAt ?? new Date().toISOString(),
|
|
127
|
+
endedAt: new Date().toISOString(),
|
|
128
|
+
...(selection === undefined ? {} : { selection }),
|
|
129
|
+
outcome: final?.stopReason ?? "unknown",
|
|
130
|
+
...(finalText === undefined ? {} : { text: finalText }),
|
|
131
|
+
...(totals === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: {
|
|
134
|
+
usage: {
|
|
135
|
+
input: totals.input,
|
|
136
|
+
output: totals.output,
|
|
137
|
+
cacheRead: totals.cacheRead,
|
|
138
|
+
cacheWrite: totals.cacheWrite,
|
|
139
|
+
},
|
|
140
|
+
...(totals.cost === undefined ? {} : { cost: totals.cost }),
|
|
141
|
+
}),
|
|
142
|
+
toolCalls,
|
|
143
|
+
errorCount,
|
|
144
|
+
incomplete: !complete,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
package/extension/planner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { validAttempt } from "../src/attempt.ts";
|
|
2
3
|
|
|
3
4
|
const codeless = fileURLToPath(new URL("../bin/codeless", import.meta.url));
|
|
4
5
|
const thinkingLevels = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
@@ -322,13 +323,22 @@ export default function plannerExtension(pi) {
|
|
|
322
323
|
if (execution.code !== 0) {
|
|
323
324
|
throw new Error(output || `codeless dispatch failed with exit code ${execution.code}`);
|
|
324
325
|
}
|
|
326
|
+
let attempt;
|
|
327
|
+
try {
|
|
328
|
+
attempt = JSON.parse(execution.stdout);
|
|
329
|
+
} catch {
|
|
330
|
+
throw new Error("Codeless returned an invalid implementer attempt");
|
|
331
|
+
}
|
|
332
|
+
if (!validAttempt(attempt, attempt?.stream, attempt?.change)) {
|
|
333
|
+
throw new Error("Codeless returned an invalid implementer attempt");
|
|
334
|
+
}
|
|
325
335
|
pi.sendUserMessage(`/review ${JSON.stringify(changePath)}`, {
|
|
326
336
|
deliverAs: "steer",
|
|
327
337
|
expandPromptTemplates: true,
|
|
328
338
|
});
|
|
329
339
|
return {
|
|
330
|
-
content: [{ type: "text", text:
|
|
331
|
-
details: { changePath },
|
|
340
|
+
content: [{ type: "text", text: attempt.text || "Implementer settled." }],
|
|
341
|
+
details: { changePath, attempt },
|
|
332
342
|
};
|
|
333
343
|
},
|
|
334
344
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dpeek/codeless",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "An attended planner and implementer workflow for parallel capability development",
|
|
5
5
|
"homepage": "https://github.com/dpeek/codeless#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"check": "oxfmt --config .oxfmtrc.json --write . && oxlint --config .oxlintrc.json --fix --type-aware --type-check . && bun run test",
|
|
30
|
-
"
|
|
30
|
+
"release": "bun ./scripts/release.ts",
|
|
31
31
|
"test": "bun test ./test --dots"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
package/spec/workflow.md
CHANGED
|
@@ -49,6 +49,18 @@ Git-ignored, and every linked worktree resolves the same primary-checkout state:
|
|
|
49
49
|
metrics/<slug>/NNN.json
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
`codeless init` is the explicit, idempotent bootstrap for a configured project.
|
|
53
|
+
It requires the configured integration branch to exist and creates the shared
|
|
54
|
+
state directories plus that branch's worktree only at
|
|
55
|
+
`<workspace>/worktree/<integration-branch>`. It reuses only the exact registered
|
|
56
|
+
canonical checkout. An occupied target, a branch registered elsewhere, invalid
|
|
57
|
+
checkout, or ambiguous Git registration stops unchanged; init never switches
|
|
58
|
+
branches, moves worktrees, or repairs conflicts. With the default workspace,
|
|
59
|
+
it accepts a repository ignore rule only when it ignores the state path without
|
|
60
|
+
covering configuration or configured prompts, otherwise appending the narrow
|
|
61
|
+
`/.codeless/state/` rule. An absolute workspace never changes repository
|
|
62
|
+
ignores. No other command bootstraps this layout.
|
|
63
|
+
|
|
52
64
|
`planner.md` owns decisions, approvals, review outcomes, landing history, and
|
|
53
65
|
the context needed by a fresh planner. `change.md` is the editable current
|
|
54
66
|
proposal. `changes/NNN.md` is the immutable-by-policy approved input to one
|
|
@@ -108,7 +120,23 @@ right-hand Herdr pane only when that pane is an available shell or the expected
|
|
|
108
120
|
idle implementer, starts a fresh ephemeral Pi implementer in the stream
|
|
109
121
|
worktree, submits `/implement`, and waits for at most one hour.
|
|
110
122
|
|
|
111
|
-
Successful dispatch
|
|
123
|
+
Successful dispatch loads the package-owned reporting extension while retaining
|
|
124
|
+
`--no-session` and passes its report configuration through that extension's
|
|
125
|
+
explicit Pi string flag, then returns one normalized attempt to the planner tool
|
|
126
|
+
before it queues the expanded `/review` prompt. Attempts have a stable ID and
|
|
127
|
+
capture only stream/change/role, start and settlement timestamps, Pi's actual
|
|
128
|
+
settled provider/model/thinking selection, terminal outcome and final text,
|
|
129
|
+
full-session Pi input/output/cache usage (including tool results, compaction,
|
|
130
|
+
and branch summaries), available Pi model cost estimate with USD currency and
|
|
131
|
+
source, and tool/error counts. Cost is omitted when Pi did not supply valid
|
|
132
|
+
cost totals. They do not retain prompts, source, credentials, thinking, or a
|
|
133
|
+
transcript. The extension writes its narrow report atomically once, then
|
|
134
|
+
remains disarmed for remediation; Codeless atomically deduplicates it inside the
|
|
135
|
+
per-change metric record, rejecting a
|
|
136
|
+
conflicting duplicate ID. Missing, malformed, or unwritable collection warns
|
|
137
|
+
and yields an explicitly incomplete attempt when possible without failing or
|
|
138
|
+
repeating a settled implementation.
|
|
139
|
+
|
|
112
140
|
The planner inspects the full diff and relevant code, checks the approved
|
|
113
141
|
acceptance criteria, and runs focused checks when the implementation output is
|
|
114
142
|
insufficient. Remediation reuses the same implementer context. Once approved,
|
|
@@ -116,10 +144,8 @@ the planner records the review result, exits the implementer so its pane returns
|
|
|
116
144
|
to a shell, and follows the commit-and-land prompt without another approval
|
|
117
145
|
round.
|
|
118
146
|
|
|
119
|
-
Dispatch and remediation do not retry automatically.
|
|
120
|
-
|
|
121
|
-
remediation and implementer shutdown are still performed through prompt-owned
|
|
122
|
-
Herdr commands.
|
|
147
|
+
Dispatch and remediation do not retry automatically. Remediation and implementer
|
|
148
|
+
shutdown are still performed through prompt-owned Herdr commands.
|
|
123
149
|
|
|
124
150
|
## Commit and landing
|
|
125
151
|
|
|
@@ -162,10 +188,11 @@ before another implementation.
|
|
|
162
188
|
## Local workflow metrics
|
|
163
189
|
|
|
164
190
|
The first dispatch for a stream and numbered change creates one atomic local
|
|
165
|
-
metric record.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
191
|
+
metric record. Every accepted dispatch creates a new attempt ID; re-ingesting an
|
|
192
|
+
attempt ID is atomic and idempotent, while the original dispatch timestamp stays
|
|
193
|
+
unchanged. Successful landing adds its timestamp and commit, or creates a landed
|
|
194
|
+
record with unavailable elapsed time when dispatch collection was unavailable.
|
|
195
|
+
Collection warnings do not change dispatch or landing outcomes.
|
|
169
196
|
|
|
170
197
|
`codeless metrics` reports every recorded stream and a project total with:
|
|
171
198
|
|
|
@@ -174,9 +201,9 @@ warnings do not change dispatch or landing outcomes.
|
|
|
174
201
|
- total and average dispatch-to-land wall-clock time.
|
|
175
202
|
|
|
176
203
|
The measurements are prospective, local observations. They are not journal
|
|
177
|
-
state, an approval source, or a recovery mechanism.
|
|
178
|
-
|
|
179
|
-
|
|
204
|
+
state, an approval source, or a recovery mechanism. Attempt usage and cost are
|
|
205
|
+
stored for later aggregation; `codeless metrics`, review rework, and failure
|
|
206
|
+
breakdowns do not yet report them.
|
|
180
207
|
|
|
181
208
|
## Limits
|
|
182
209
|
|
package/src/attempt.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type Attempt = {
|
|
2
|
+
id: string;
|
|
3
|
+
stream: string;
|
|
4
|
+
change: string;
|
|
5
|
+
role: "implementer";
|
|
6
|
+
startedAt: string;
|
|
7
|
+
endedAt: string;
|
|
8
|
+
selection?: { provider: string; model: string; thinking: string };
|
|
9
|
+
outcome: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
usage?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
12
|
+
cost?: { amount: number; currency: string; source: string };
|
|
13
|
+
toolCalls: number;
|
|
14
|
+
errorCount: number;
|
|
15
|
+
incomplete: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function validAttempt(value: unknown, stream: string, change: string): value is Attempt {
|
|
19
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
20
|
+
const attempt = value as Record<string, unknown>;
|
|
21
|
+
const allowed = new Set([
|
|
22
|
+
"id",
|
|
23
|
+
"stream",
|
|
24
|
+
"change",
|
|
25
|
+
"role",
|
|
26
|
+
"startedAt",
|
|
27
|
+
"endedAt",
|
|
28
|
+
"selection",
|
|
29
|
+
"outcome",
|
|
30
|
+
"text",
|
|
31
|
+
"usage",
|
|
32
|
+
"cost",
|
|
33
|
+
"toolCalls",
|
|
34
|
+
"errorCount",
|
|
35
|
+
"incomplete",
|
|
36
|
+
]);
|
|
37
|
+
const strings = ["id", "stream", "change", "role", "startedAt", "endedAt", "outcome"];
|
|
38
|
+
if (
|
|
39
|
+
!Object.keys(attempt).every((key) => allowed.has(key)) ||
|
|
40
|
+
!strings.every((key) => typeof attempt[key] === "string" && attempt[key].length > 0) ||
|
|
41
|
+
!Number.isFinite(Date.parse(attempt["startedAt"] as string)) ||
|
|
42
|
+
!Number.isFinite(Date.parse(attempt["endedAt"] as string)) ||
|
|
43
|
+
attempt["stream"] !== stream ||
|
|
44
|
+
attempt["change"] !== change ||
|
|
45
|
+
attempt["role"] !== "implementer" ||
|
|
46
|
+
!Number.isSafeInteger(attempt["toolCalls"]) ||
|
|
47
|
+
(attempt["toolCalls"] as number) < 0 ||
|
|
48
|
+
!Number.isSafeInteger(attempt["errorCount"]) ||
|
|
49
|
+
(attempt["errorCount"] as number) < 0 ||
|
|
50
|
+
typeof attempt["incomplete"] !== "boolean"
|
|
51
|
+
)
|
|
52
|
+
return false;
|
|
53
|
+
const selection = attempt["selection"];
|
|
54
|
+
if (
|
|
55
|
+
selection !== undefined &&
|
|
56
|
+
(typeof selection !== "object" ||
|
|
57
|
+
selection === null ||
|
|
58
|
+
["provider", "model", "thinking"].some(
|
|
59
|
+
(key) =>
|
|
60
|
+
typeof (selection as Record<string, unknown>)[key] !== "string" ||
|
|
61
|
+
!(selection as Record<string, unknown>)[key],
|
|
62
|
+
))
|
|
63
|
+
)
|
|
64
|
+
return false;
|
|
65
|
+
const usage = attempt["usage"];
|
|
66
|
+
if (
|
|
67
|
+
usage !== undefined &&
|
|
68
|
+
(typeof usage !== "object" ||
|
|
69
|
+
usage === null ||
|
|
70
|
+
["input", "output", "cacheRead", "cacheWrite"].some(
|
|
71
|
+
(key) =>
|
|
72
|
+
!Number.isSafeInteger((usage as Record<string, unknown>)[key]) ||
|
|
73
|
+
((usage as Record<string, unknown>)[key] as number) < 0,
|
|
74
|
+
))
|
|
75
|
+
)
|
|
76
|
+
return false;
|
|
77
|
+
if (
|
|
78
|
+
attempt["incomplete"] === false &&
|
|
79
|
+
(selection === undefined ||
|
|
80
|
+
usage === undefined ||
|
|
81
|
+
attempt["outcome"] === "unknown" ||
|
|
82
|
+
typeof attempt["text"] !== "string" ||
|
|
83
|
+
attempt["text"].length === 0)
|
|
84
|
+
)
|
|
85
|
+
return false;
|
|
86
|
+
const cost = attempt["cost"];
|
|
87
|
+
if (
|
|
88
|
+
cost !== undefined &&
|
|
89
|
+
(usage === undefined ||
|
|
90
|
+
typeof cost !== "object" ||
|
|
91
|
+
cost === null ||
|
|
92
|
+
!Number.isFinite((cost as Record<string, unknown>)["amount"]) ||
|
|
93
|
+
((cost as Record<string, unknown>)["amount"] as number) < 0 ||
|
|
94
|
+
typeof (cost as Record<string, unknown>)["currency"] !== "string" ||
|
|
95
|
+
!(cost as Record<string, unknown>)["currency"] ||
|
|
96
|
+
typeof (cost as Record<string, unknown>)["source"] !== "string" ||
|
|
97
|
+
!(cost as Record<string, unknown>)["source"])
|
|
98
|
+
)
|
|
99
|
+
return false;
|
|
100
|
+
return attempt["text"] === undefined || typeof attempt["text"] === "string";
|
|
101
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readdirSync,
|
|
9
9
|
realpathSync,
|
|
10
10
|
rmdirSync,
|
|
11
|
+
statSync,
|
|
11
12
|
unlinkSync,
|
|
12
13
|
writeFileSync,
|
|
13
14
|
} from "node:fs";
|
|
@@ -15,11 +16,13 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
15
16
|
import { createHash } from "node:crypto";
|
|
16
17
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
18
|
|
|
18
|
-
import { metricReport, recordDispatch, recordLanding } from "./metrics.ts";
|
|
19
|
+
import { metricReport, recordAttempt, recordDispatch, recordLanding } from "./metrics.ts";
|
|
20
|
+
import { type Attempt, validAttempt } from "./attempt.ts";
|
|
19
21
|
import { roleSelectionArguments, roleSelectionSummary, validateRoleSelection } from "./pi.ts";
|
|
20
22
|
import { readProject } from "./project.ts";
|
|
21
23
|
|
|
22
24
|
const usage = `Usage:
|
|
25
|
+
codeless init
|
|
23
26
|
codeless create <slug>
|
|
24
27
|
codeless open <slug>
|
|
25
28
|
codeless planner <slug>
|
|
@@ -36,9 +39,14 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
36
39
|
return;
|
|
37
40
|
}
|
|
38
41
|
const repository = canonicalPath(run("git", ["rev-parse", "--show-toplevel"]).trim());
|
|
39
|
-
const
|
|
42
|
+
const project = readProject(repository);
|
|
43
|
+
const { integrationBranch } = project;
|
|
40
44
|
run("git", ["check-ref-format", "--branch", integrationBranch], repository);
|
|
41
45
|
const plannerExtension = resolve(import.meta.dir, "../extension/planner.js");
|
|
46
|
+
const implementerReportingExtension = resolve(
|
|
47
|
+
import.meta.dir,
|
|
48
|
+
"../extension/implementer-reporting.js",
|
|
49
|
+
);
|
|
42
50
|
const configuredWorkspace = Bun.spawnSync(
|
|
43
51
|
["git", "config", "--local", "--get", "codeless.workspaceRoot"],
|
|
44
52
|
{ cwd: repository },
|
|
@@ -123,28 +131,146 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
123
131
|
.join("-");
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
type Worktree = { path: string; branch?: string };
|
|
135
|
+
|
|
136
|
+
function registeredWorktrees(): Worktree[] {
|
|
137
|
+
return run("git", ["worktree", "list", "--porcelain", "-z"], repository)
|
|
138
|
+
.split("\0\0")
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.map((entry) => {
|
|
141
|
+
const fields = entry.split("\0");
|
|
131
142
|
const path = fields.find((field) => field.startsWith("worktree "))?.slice(9);
|
|
132
|
-
if (path
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
if (path === undefined) throw new Error("Git did not report a worktree path");
|
|
144
|
+
const branch = fields.find((field) => field.startsWith("branch "))?.slice(7);
|
|
145
|
+
return { path: canonicalPath(path), ...(branch === undefined ? {} : { branch }) };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function integrationWorktree(): string {
|
|
150
|
+
const matches = registeredWorktrees().filter(
|
|
151
|
+
(worktree) => worktree.branch === `refs/heads/${integrationBranch}`,
|
|
152
|
+
);
|
|
153
|
+
if (matches.length !== 1)
|
|
154
|
+
throw new Error(`${integrationBranch} needs exactly one dedicated integration worktree`);
|
|
155
|
+
return matches[0]!.path;
|
|
136
156
|
}
|
|
137
157
|
|
|
138
158
|
function primaryWorktree(): string {
|
|
139
|
-
const
|
|
140
|
-
"\0\0",
|
|
141
|
-
)[0];
|
|
142
|
-
const path = entry
|
|
143
|
-
?.split("\0")
|
|
144
|
-
.find((field) => field.startsWith("worktree "))
|
|
145
|
-
?.slice(9);
|
|
159
|
+
const path = registeredWorktrees()[0]?.path;
|
|
146
160
|
if (path === undefined) throw new Error("Git did not report a primary worktree");
|
|
147
|
-
return
|
|
161
|
+
return path;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function init(): void {
|
|
165
|
+
run("git", ["show-ref", "--verify", `refs/heads/${integrationBranch}`], repository);
|
|
166
|
+
const primary = primaryWorktree();
|
|
167
|
+
const target = canonicalPath(join(workspaceRoot, "worktree", integrationBranch));
|
|
168
|
+
const matchingBranch = registeredWorktrees().filter(
|
|
169
|
+
(worktree) => worktree.branch === `refs/heads/${integrationBranch}`,
|
|
170
|
+
);
|
|
171
|
+
const targetExists = existsSync(target);
|
|
172
|
+
|
|
173
|
+
let workspaceParent = workspaceRoot;
|
|
174
|
+
while (!existsSync(workspaceParent)) workspaceParent = dirname(workspaceParent);
|
|
175
|
+
if (!statSync(workspaceParent).isDirectory()) {
|
|
176
|
+
throw new Error(`Workspace parent is not a directory: ${workspaceParent}`);
|
|
177
|
+
}
|
|
178
|
+
const worktreeRoot = join(workspaceRoot, "worktree");
|
|
179
|
+
const stateDirectories: [string, string][] = [
|
|
180
|
+
["Workspace", workspaceRoot],
|
|
181
|
+
["Workspace stream path", join(workspaceRoot, "stream")],
|
|
182
|
+
["Workspace worktree path", worktreeRoot],
|
|
183
|
+
["Workspace metrics path", join(workspaceRoot, "metrics")],
|
|
184
|
+
];
|
|
185
|
+
for (const [label, path] of stateDirectories) {
|
|
186
|
+
if (existsSync(path) && !statSync(path).isDirectory()) {
|
|
187
|
+
throw new Error(`${label} is not a directory: ${path}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const defaultWorkspace = workspaceRoot === canonicalPath(join(primary, ".codeless", "state"));
|
|
191
|
+
const ignoreFile = join(primary, ".gitignore");
|
|
192
|
+
let stateIgnored = false;
|
|
193
|
+
if (defaultWorkspace) {
|
|
194
|
+
if (existsSync(ignoreFile) && !statSync(ignoreFile).isFile()) {
|
|
195
|
+
throw new Error(`Repository ignore file is not a file: ${ignoreFile}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ignoredByRepository(path: string): boolean {
|
|
199
|
+
const effective = Bun.spawnSync(["git", "check-ignore", "-q", "--no-index", path], {
|
|
200
|
+
cwd: primary,
|
|
201
|
+
env: process.env,
|
|
202
|
+
stdin: "ignore",
|
|
203
|
+
stdout: "ignore",
|
|
204
|
+
stderr: "pipe",
|
|
205
|
+
});
|
|
206
|
+
if (effective.exitCode === 1) return false;
|
|
207
|
+
if (effective.exitCode !== 0) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
effective.stderr.toString().trim() || "Could not inspect repository ignores",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const source = run("git", ["check-ignore", "-v", "--no-index", path], primary).split(
|
|
213
|
+
":",
|
|
214
|
+
1,
|
|
215
|
+
)[0];
|
|
216
|
+
return source === ".gitignore" || source === ignoreFile;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const protectedPaths = [
|
|
220
|
+
".codeless/config.json",
|
|
221
|
+
...["change", "implement", "review", "commit"].map((name) =>
|
|
222
|
+
join(project.prompts, `${name}.md`),
|
|
223
|
+
),
|
|
224
|
+
];
|
|
225
|
+
if (protectedPaths.some((path) => ignoredByRepository(path))) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Repository ignore rule conflicts with Codeless configuration or prompts; narrow ${ignoreFile} to /.codeless/state/`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
stateIgnored = ignoredByRepository(join(".codeless", "state", ".codeless-init-probe"));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (matchingBranch.length > 1) {
|
|
234
|
+
throw new Error(`Git reports ${integrationBranch} checked out in multiple worktrees`);
|
|
235
|
+
}
|
|
236
|
+
if (matchingBranch.length === 1 && matchingBranch[0]!.path !== target) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`${integrationBranch} is checked out at ${matchingBranch[0]!.path}, expected ${target}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (matchingBranch.length === 1 && (!targetExists || !statSync(target).isDirectory())) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Git registers ${integrationBranch} at invalid integration worktree ${target}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
matchingBranch.length === 1 &&
|
|
248
|
+
run("git", ["branch", "--show-current"], target).trim() !== integrationBranch
|
|
249
|
+
) {
|
|
250
|
+
throw new Error(`Integration worktree is not on ${integrationBranch}: ${target}`);
|
|
251
|
+
}
|
|
252
|
+
if (matchingBranch.length === 0 && targetExists) {
|
|
253
|
+
throw new Error(`Integration worktree target is occupied: ${target}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (defaultWorkspace && !stateIgnored) {
|
|
257
|
+
const currentIgnore = existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "";
|
|
258
|
+
appendFileSync(
|
|
259
|
+
ignoreFile,
|
|
260
|
+
`${currentIgnore.length > 0 && !currentIgnore.endsWith("\n") ? "\n" : ""}/.codeless/state/\n`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
mkdirSync(join(workspaceRoot, "stream"), { recursive: true });
|
|
264
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
265
|
+
mkdirSync(join(workspaceRoot, "metrics"), { recursive: true });
|
|
266
|
+
if (matchingBranch.length === 0) {
|
|
267
|
+
run("git", ["worktree", "add", target, integrationBranch], repository);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(`Integration branch: ${integrationBranch}`);
|
|
271
|
+
console.log(`Primary checkout: ${primary}`);
|
|
272
|
+
console.log(`Workspace: ${workspaceRoot}`);
|
|
273
|
+
console.log(`Integration worktree: ${target}`);
|
|
148
274
|
}
|
|
149
275
|
|
|
150
276
|
function requireDirection(slug: string, worktree: string): string {
|
|
@@ -313,6 +439,48 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
313
439
|
}
|
|
314
440
|
}
|
|
315
441
|
|
|
442
|
+
function incompleteAttempt(
|
|
443
|
+
id: string,
|
|
444
|
+
slug: string,
|
|
445
|
+
number: string,
|
|
446
|
+
selection: { provider: string; model: string; thinking: string },
|
|
447
|
+
): Attempt {
|
|
448
|
+
const timestamp = new Date().toISOString();
|
|
449
|
+
return {
|
|
450
|
+
id,
|
|
451
|
+
stream: slug,
|
|
452
|
+
change: number,
|
|
453
|
+
role: "implementer",
|
|
454
|
+
startedAt: timestamp,
|
|
455
|
+
endedAt: timestamp,
|
|
456
|
+
selection,
|
|
457
|
+
outcome: "unknown",
|
|
458
|
+
toolCalls: 0,
|
|
459
|
+
errorCount: 0,
|
|
460
|
+
incomplete: true,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function collectedAttempt(value: unknown, fallback: Attempt): Attempt {
|
|
465
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return fallback;
|
|
466
|
+
const attempt = value as Partial<Attempt>;
|
|
467
|
+
if (
|
|
468
|
+
attempt.id !== fallback.id ||
|
|
469
|
+
attempt.stream !== fallback.stream ||
|
|
470
|
+
attempt.change !== fallback.change ||
|
|
471
|
+
attempt.role !== "implementer" ||
|
|
472
|
+
typeof attempt.startedAt !== "string" ||
|
|
473
|
+
typeof attempt.endedAt !== "string" ||
|
|
474
|
+
typeof attempt.outcome !== "string" ||
|
|
475
|
+
typeof attempt.toolCalls !== "number" ||
|
|
476
|
+
typeof attempt.errorCount !== "number" ||
|
|
477
|
+
attempt.incomplete !== false ||
|
|
478
|
+
!validAttempt(attempt, fallback.stream, fallback.change)
|
|
479
|
+
)
|
|
480
|
+
return fallback;
|
|
481
|
+
return attempt;
|
|
482
|
+
}
|
|
483
|
+
|
|
316
484
|
function latestChangeNumber(slug: string): string {
|
|
317
485
|
const change = readdirSync(join(workspaceRoot, "stream", slug, "changes"))
|
|
318
486
|
.filter((file) => /^\d{3}\.md$/.test(file))
|
|
@@ -538,9 +706,11 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
538
706
|
requireClean(worktree, branch);
|
|
539
707
|
const prompts = promptDirectory(worktree);
|
|
540
708
|
const selection = readProject(worktree).implementer;
|
|
709
|
+
const attemptId = crypto.randomUUID();
|
|
710
|
+
const reportPath = join(workspaceRoot, "metrics", slug, `.attempt-${attemptId}.json`);
|
|
711
|
+
const fallbackAttempt = incompleteAttempt(attemptId, slug, number, selection);
|
|
541
712
|
observe("dispatch metrics", () => recordDispatch(workspaceRoot, slug, number));
|
|
542
713
|
await validateRoleSelection("implementer", selection, worktree);
|
|
543
|
-
console.log(roleSelectionSummary("implementer", selection));
|
|
544
714
|
|
|
545
715
|
const plannerPane = string(process.env["HERDR_PANE_ID"], "HERDR_PANE_ID");
|
|
546
716
|
const plannerProcesses = foregroundProcesses(paneProcessInfo(plannerPane));
|
|
@@ -592,6 +762,12 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
592
762
|
}
|
|
593
763
|
requirePaneShell(implementerPane, worktree);
|
|
594
764
|
|
|
765
|
+
const collection = JSON.stringify({
|
|
766
|
+
id: attemptId,
|
|
767
|
+
stream: slug,
|
|
768
|
+
change: number,
|
|
769
|
+
path: reportPath,
|
|
770
|
+
});
|
|
595
771
|
const started = herdr([
|
|
596
772
|
"agent",
|
|
597
773
|
"start",
|
|
@@ -602,6 +778,10 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
602
778
|
implementerPane,
|
|
603
779
|
"--",
|
|
604
780
|
"--no-session",
|
|
781
|
+
"--extension",
|
|
782
|
+
implementerReportingExtension,
|
|
783
|
+
"--codeless-attempt",
|
|
784
|
+
collection,
|
|
605
785
|
"--name",
|
|
606
786
|
`${slug}-impl`,
|
|
607
787
|
...roleSelectionArguments(selection),
|
|
@@ -618,7 +798,7 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
618
798
|
throw new Error(`${implementerName} started in ${startedCwd}, expected ${worktree}`);
|
|
619
799
|
}
|
|
620
800
|
|
|
621
|
-
|
|
801
|
+
run("herdr", [
|
|
622
802
|
"agent",
|
|
623
803
|
"prompt",
|
|
624
804
|
implementerName,
|
|
@@ -627,7 +807,19 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
627
807
|
"--timeout",
|
|
628
808
|
"3600000",
|
|
629
809
|
]);
|
|
630
|
-
|
|
810
|
+
let attempt = fallbackAttempt;
|
|
811
|
+
try {
|
|
812
|
+
attempt = collectedAttempt(JSON.parse(readFileSync(reportPath, "utf8")), fallbackAttempt);
|
|
813
|
+
if (attempt === fallbackAttempt) throw new Error("report did not match its dispatch attempt");
|
|
814
|
+
} catch (error) {
|
|
815
|
+
console.error(
|
|
816
|
+
`codeless: warning: could not collect implementer attempt: ${error instanceof Error ? error.message : String(error)}`,
|
|
817
|
+
);
|
|
818
|
+
} finally {
|
|
819
|
+
if (existsSync(reportPath)) unlinkSync(reportPath);
|
|
820
|
+
}
|
|
821
|
+
observe("implementer attempt", () => recordAttempt(workspaceRoot, slug, number, attempt));
|
|
822
|
+
console.log(JSON.stringify(attempt));
|
|
631
823
|
}
|
|
632
824
|
|
|
633
825
|
async function launchPlanner(slug: string): Promise<void> {
|
|
@@ -785,6 +977,11 @@ export async function runCodeless(args: string[]): Promise<void> {
|
|
|
785
977
|
}
|
|
786
978
|
}
|
|
787
979
|
|
|
980
|
+
if (action === "init") {
|
|
981
|
+
if (target !== undefined || details.length > 0) throw new Error(usage);
|
|
982
|
+
init();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
788
985
|
if (action === "metrics") {
|
|
789
986
|
if (target !== undefined || details.length > 0) throw new Error(usage);
|
|
790
987
|
for (const line of metricReport(workspaceRoot)) console.log(line);
|
package/src/metrics.ts
CHANGED
|
@@ -5,17 +5,21 @@ import {
|
|
|
5
5
|
readFileSync,
|
|
6
6
|
readdirSync,
|
|
7
7
|
renameSync,
|
|
8
|
+
rmdirSync,
|
|
8
9
|
unlinkSync,
|
|
9
10
|
writeFileSync,
|
|
10
11
|
} from "node:fs";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
12
13
|
|
|
14
|
+
import { type Attempt, validAttempt } from "./attempt.ts";
|
|
15
|
+
|
|
13
16
|
export type Metric = {
|
|
14
17
|
stream: string;
|
|
15
18
|
change: string;
|
|
16
19
|
dispatchedAt?: string;
|
|
17
20
|
landedAt?: string;
|
|
18
21
|
landedCommit?: string;
|
|
22
|
+
attempts?: Record<string, Attempt>;
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
function metricPath(workspaceRoot: string, stream: string, change: string): string {
|
|
@@ -32,7 +36,16 @@ function readMetric(path: string): Metric {
|
|
|
32
36
|
typeof metric["change"] !== "string" ||
|
|
33
37
|
(metric["dispatchedAt"] !== undefined && typeof metric["dispatchedAt"] !== "string") ||
|
|
34
38
|
(metric["landedAt"] !== undefined && typeof metric["landedAt"] !== "string") ||
|
|
35
|
-
(metric["landedCommit"] !== undefined && typeof metric["landedCommit"] !== "string")
|
|
39
|
+
(metric["landedCommit"] !== undefined && typeof metric["landedCommit"] !== "string") ||
|
|
40
|
+
(metric["attempts"] !== undefined &&
|
|
41
|
+
(typeof metric["attempts"] !== "object" ||
|
|
42
|
+
metric["attempts"] === null ||
|
|
43
|
+
Array.isArray(metric["attempts"]) ||
|
|
44
|
+
!Object.entries(metric["attempts"] as Record<string, unknown>).every(
|
|
45
|
+
([id, attempt]) =>
|
|
46
|
+
validAttempt(attempt, metric["stream"] as string, metric["change"] as string) &&
|
|
47
|
+
attempt.id === id,
|
|
48
|
+
)))
|
|
36
49
|
)
|
|
37
50
|
throw new Error(`${path} is not a metric record`);
|
|
38
51
|
return metric as Metric;
|
|
@@ -73,6 +86,50 @@ export function recordDispatch(workspaceRoot: string, stream: string, change: st
|
|
|
73
86
|
readMetric(path);
|
|
74
87
|
}
|
|
75
88
|
|
|
89
|
+
function withMetricLock(path: string, action: () => void): void {
|
|
90
|
+
const lock = `${path}.lock`;
|
|
91
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
92
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(lock);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
97
|
+
Bun.sleepSync(10);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
action();
|
|
102
|
+
} finally {
|
|
103
|
+
rmdirSync(lock);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`could not acquire metric lock ${lock}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function recordAttempt(
|
|
111
|
+
workspaceRoot: string,
|
|
112
|
+
stream: string,
|
|
113
|
+
change: string,
|
|
114
|
+
attempt: Attempt,
|
|
115
|
+
): void {
|
|
116
|
+
if (!validAttempt(attempt, stream, change))
|
|
117
|
+
throw new Error("attempt is not a valid metric attempt");
|
|
118
|
+
const path = metricPath(workspaceRoot, stream, change);
|
|
119
|
+
withMetricLock(path, () => {
|
|
120
|
+
const metric = existsSync(path) ? readMetric(path) : { stream, change };
|
|
121
|
+
if (metric.stream !== stream || metric.change !== change)
|
|
122
|
+
throw new Error(`${path} does not match ${stream} change ${change}`);
|
|
123
|
+
const existing = metric.attempts?.[attempt.id];
|
|
124
|
+
if (existing !== undefined) {
|
|
125
|
+
if (JSON.stringify(existing) !== JSON.stringify(attempt))
|
|
126
|
+
throw new Error(`attempt ${attempt.id} conflicts with its existing metric record`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
writeMetric(path, { ...metric, attempts: { ...metric.attempts, [attempt.id]: attempt } });
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
76
133
|
export function recordLanding(
|
|
77
134
|
workspaceRoot: string,
|
|
78
135
|
stream: string,
|