@yagni-app/code-staging 0.0.0 → 0.1.0-staging.997.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/LICENSE.md +55 -0
- package/README.md +166 -0
- package/dist/branding.d.ts +25 -0
- package/dist/branding.js +27 -0
- package/dist/claudeCompat.d.ts +78 -0
- package/dist/claudeCompat.js +198 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.js +326 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +28 -0
- package/dist/credentials.d.ts +41 -0
- package/dist/credentials.js +75 -0
- package/dist/distribution.d.ts +15 -0
- package/dist/distribution.js +45 -0
- package/dist/doctor.d.ts +94 -0
- package/dist/doctor.js +343 -0
- package/dist/extension/advisor.d.ts +84 -0
- package/dist/extension/advisor.js +101 -0
- package/dist/extension/askAdvisorTool.d.ts +83 -0
- package/dist/extension/askAdvisorTool.js +188 -0
- package/dist/extension/askYagniTool.d.ts +39 -0
- package/dist/extension/askYagniTool.js +63 -0
- package/dist/extension/bless.d.ts +45 -0
- package/dist/extension/bless.js +74 -0
- package/dist/extension/branding.d.ts +48 -0
- package/dist/extension/branding.js +119 -0
- package/dist/extension/chipEditor.d.ts +117 -0
- package/dist/extension/chipEditor.js +369 -0
- package/dist/extension/config.d.ts +112 -0
- package/dist/extension/config.js +108 -0
- package/dist/extension/costHud.d.ts +73 -0
- package/dist/extension/costHud.js +123 -0
- package/dist/extension/decisionCapture.d.ts +52 -0
- package/dist/extension/decisionCapture.js +66 -0
- package/dist/extension/decisions.d.ts +83 -0
- package/dist/extension/decisions.js +200 -0
- package/dist/extension/diagnostics.d.ts +41 -0
- package/dist/extension/diagnostics.js +110 -0
- package/dist/extension/index.d.ts +116 -0
- package/dist/extension/index.js +388 -0
- package/dist/extension/initDone.d.ts +28 -0
- package/dist/extension/initDone.js +66 -0
- package/dist/extension/initPass.d.ts +152 -0
- package/dist/extension/initPass.js +394 -0
- package/dist/extension/nextWorkTool.d.ts +51 -0
- package/dist/extension/nextWorkTool.js +80 -0
- package/dist/extension/permission.d.ts +91 -0
- package/dist/extension/permission.js +236 -0
- package/dist/extension/pipeline/activity.d.ts +37 -0
- package/dist/extension/pipeline/activity.js +151 -0
- package/dist/extension/pipeline/activityFeed.d.ts +59 -0
- package/dist/extension/pipeline/activityFeed.js +175 -0
- package/dist/extension/pipeline/budget.d.ts +48 -0
- package/dist/extension/pipeline/budget.js +68 -0
- package/dist/extension/pipeline/checkpoint.d.ts +31 -0
- package/dist/extension/pipeline/checkpoint.js +176 -0
- package/dist/extension/pipeline/eval.d.ts +168 -0
- package/dist/extension/pipeline/eval.js +182 -0
- package/dist/extension/pipeline/events.d.ts +56 -0
- package/dist/extension/pipeline/events.js +147 -0
- package/dist/extension/pipeline/findings.d.ts +42 -0
- package/dist/extension/pipeline/findings.js +144 -0
- package/dist/extension/pipeline/finish.d.ts +128 -0
- package/dist/extension/pipeline/finish.js +307 -0
- package/dist/extension/pipeline/goCommand.d.ts +128 -0
- package/dist/extension/pipeline/goCommand.js +972 -0
- package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
- package/dist/extension/pipeline/goCompareCommand.js +184 -0
- package/dist/extension/pipeline/goFlags.d.ts +40 -0
- package/dist/extension/pipeline/goFlags.js +46 -0
- package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
- package/dist/extension/pipeline/goStatusCommands.js +188 -0
- package/dist/extension/pipeline/invocation.d.ts +45 -0
- package/dist/extension/pipeline/invocation.js +64 -0
- package/dist/extension/pipeline/orchestrator.d.ts +131 -0
- package/dist/extension/pipeline/orchestrator.js +636 -0
- package/dist/extension/pipeline/personas.d.ts +44 -0
- package/dist/extension/pipeline/personas.js +248 -0
- package/dist/extension/pipeline/resilience.d.ts +85 -0
- package/dist/extension/pipeline/resilience.js +166 -0
- package/dist/extension/pipeline/resume.d.ts +18 -0
- package/dist/extension/pipeline/resume.js +106 -0
- package/dist/extension/pipeline/runRegistry.d.ts +112 -0
- package/dist/extension/pipeline/runRegistry.js +202 -0
- package/dist/extension/pipeline/runSession.d.ts +152 -0
- package/dist/extension/pipeline/runSession.js +167 -0
- package/dist/extension/pipeline/runState.d.ts +158 -0
- package/dist/extension/pipeline/runState.js +264 -0
- package/dist/extension/pipeline/runner.d.ts +79 -0
- package/dist/extension/pipeline/runner.js +298 -0
- package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
- package/dist/extension/pipeline/scrubSecrets.js +32 -0
- package/dist/extension/pipeline/stages.d.ts +51 -0
- package/dist/extension/pipeline/stages.js +113 -0
- package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
- package/dist/extension/pipeline/ticketResolution.js +75 -0
- package/dist/extension/pipeline/types.d.ts +436 -0
- package/dist/extension/pipeline/types.js +48 -0
- package/dist/extension/pipeline/verify.d.ts +277 -0
- package/dist/extension/pipeline/verify.js +758 -0
- package/dist/extension/pipeline/workspace.d.ts +32 -0
- package/dist/extension/pipeline/workspace.js +73 -0
- package/dist/extension/pipeline/worktree.d.ts +107 -0
- package/dist/extension/pipeline/worktree.js +200 -0
- package/dist/extension/provider.d.ts +11 -0
- package/dist/extension/provider.js +26 -0
- package/dist/extension/recall.d.ts +93 -0
- package/dist/extension/recall.js +187 -0
- package/dist/extension/recordContextTool.d.ts +38 -0
- package/dist/extension/recordContextTool.js +85 -0
- package/dist/extension/recordDecisionTool.d.ts +52 -0
- package/dist/extension/recordDecisionTool.js +102 -0
- package/dist/extension/repoDocs.d.ts +81 -0
- package/dist/extension/repoDocs.js +260 -0
- package/dist/extension/resilientFetch.d.ts +60 -0
- package/dist/extension/resilientFetch.js +133 -0
- package/dist/extension/reviewTool.d.ts +34 -0
- package/dist/extension/reviewTool.js +81 -0
- package/dist/extension/spool.d.ts +92 -0
- package/dist/extension/spool.js +266 -0
- package/dist/extension/stateHome.d.ts +2 -0
- package/dist/extension/stateHome.js +6 -0
- package/dist/extension/subagents.d.ts +135 -0
- package/dist/extension/subagents.js +281 -0
- package/dist/extension/surface.d.ts +10 -0
- package/dist/extension/surface.js +12 -0
- package/dist/extension/todos.d.ts +110 -0
- package/dist/extension/todos.js +217 -0
- package/dist/extension/tokenProvider.d.ts +93 -0
- package/dist/extension/tokenProvider.js +234 -0
- package/dist/launch.d.ts +76 -0
- package/dist/launch.js +111 -0
- package/dist/login.d.ts +45 -0
- package/dist/login.js +142 -0
- package/dist/logout.d.ts +14 -0
- package/dist/logout.js +34 -0
- package/dist/paths.d.ts +31 -0
- package/dist/paths.js +87 -0
- package/dist/piPackage.d.ts +33 -0
- package/dist/piPackage.js +71 -0
- package/dist/profiles.d.ts +80 -0
- package/dist/profiles.js +222 -0
- package/dist/refresh.d.ts +70 -0
- package/dist/refresh.js +117 -0
- package/dist/upgrade.d.ts +97 -0
- package/dist/upgrade.js +284 -0
- package/package.json +39 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE resume-decision core — the analogue of `findings.ts#shouldStop`.
|
|
3
|
+
*
|
|
4
|
+
* Given the append-only checkpoint journal for a run key and a LIVE workspace
|
|
5
|
+
* snapshot, decide whether /go can safely resume the bounded review→fix loop,
|
|
6
|
+
* must warn about a partially-built / diverged tree, or should just run fresh.
|
|
7
|
+
*
|
|
8
|
+
* Safety boundary (the whole point): we only auto-resume the EXPENSIVE half (the
|
|
9
|
+
* review→fix loop) and only when the build half fully completed AND the live git
|
|
10
|
+
* tree still matches the boundary we checkpointed. A crash mid-build, or a tree
|
|
11
|
+
* that drifted since the checkpoint, would risk double-applying edits if we
|
|
12
|
+
* replayed — so those resolve to `warn_partial` (warn + run fresh), never a
|
|
13
|
+
* silent replay. Anything unknowable fails OPEN to `fresh` (mirrors
|
|
14
|
+
* `workspaceChanged`). The fold never throws.
|
|
15
|
+
*/
|
|
16
|
+
import { EMPTY_RUN_USAGE } from "./budget.js";
|
|
17
|
+
/** Two snapshots match only when BOTH are present, tracked, and identical (HEAD + status). */
|
|
18
|
+
function snapshotsMatch(a, b) {
|
|
19
|
+
return Boolean(a) && Boolean(b) && a.tracked && b.tracked && a.head === b.head && a.status === b.status;
|
|
20
|
+
}
|
|
21
|
+
/** The last record of a given kind, or undefined. */
|
|
22
|
+
function lastOfKind(records, kind) {
|
|
23
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
24
|
+
if (records[i]?.kind === kind)
|
|
25
|
+
return records[i];
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
export function planResume(records, liveSnapshot) {
|
|
30
|
+
try {
|
|
31
|
+
if (!Array.isArray(records) || records.length === 0)
|
|
32
|
+
return { mode: "fresh" };
|
|
33
|
+
// A terminal run_finish means the run completed (even `round_cap`) — a
|
|
34
|
+
// completed run is NOT an interrupted one, so it is never resumable. A
|
|
35
|
+
// finish_done is equally terminal: the FINISH stage only runs after a clean
|
|
36
|
+
// stop, so a crash in the narrow window before run_finish lands must not
|
|
37
|
+
// read as "resumable" over a tree the FINISH commit has since moved.
|
|
38
|
+
if (records.some((r) => r?.kind === "run_finish" || r?.kind === "finish_done"))
|
|
39
|
+
return { mode: "fresh" };
|
|
40
|
+
const runStart = records.find((r) => r?.kind === "run_start");
|
|
41
|
+
if (!runStart)
|
|
42
|
+
return { mode: "fresh" }; // no anchor → nothing trustworthy to resume
|
|
43
|
+
const ticket = runStart.ticket;
|
|
44
|
+
const buildDone = lastOfKind(records, "build_done");
|
|
45
|
+
if (!buildDone) {
|
|
46
|
+
// Interrupted during the build half: the tree may hold partial edits, so
|
|
47
|
+
// replaying review against it would be wrong. Warn; the caller runs fresh.
|
|
48
|
+
return { mode: "warn_partial", reason: "build_interrupted", ticket };
|
|
49
|
+
}
|
|
50
|
+
const roundDones = records.filter((r) => r?.kind === "round_done");
|
|
51
|
+
const latest = roundDones.length > 0 ? maxByRound(roundDones) : buildDone;
|
|
52
|
+
// Unknowable tree (not a git work tree) → fail open to fresh rather than
|
|
53
|
+
// claim a resume we cannot verify.
|
|
54
|
+
if (!liveSnapshot?.tracked)
|
|
55
|
+
return { mode: "fresh" };
|
|
56
|
+
// The live tree must still be exactly where we checkpointed it; otherwise an
|
|
57
|
+
// orphaned child or a manual edit drifted it and replay is unsafe.
|
|
58
|
+
if (!snapshotsMatch(latest.snapshot, liveSnapshot)) {
|
|
59
|
+
return { mode: "warn_partial", reason: "tree_diverged", ticket };
|
|
60
|
+
}
|
|
61
|
+
if (roundDones.length === 0) {
|
|
62
|
+
// Build done, no round completed → resume at round 1 against implement output.
|
|
63
|
+
return {
|
|
64
|
+
mode: "resume_loop",
|
|
65
|
+
round: 1,
|
|
66
|
+
reviewInput: buildDone.reviewInput ?? "",
|
|
67
|
+
priorRounds: [],
|
|
68
|
+
priorUsage: buildDone.usage ?? EMPTY_RUN_USAGE,
|
|
69
|
+
...handle(runStart),
|
|
70
|
+
ticket,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const maxRound = Math.max(...roundDones.map((r) => r.round ?? 0));
|
|
74
|
+
const lastRound = roundDones.find((r) => (r.round ?? 0) === maxRound) ?? buildDone;
|
|
75
|
+
const priorRounds = roundDones
|
|
76
|
+
.slice()
|
|
77
|
+
.sort((a, b) => (a.round ?? 0) - (b.round ?? 0))
|
|
78
|
+
.map((r) => ({ round: r.round ?? 0, lensResults: [], findings: r.findings ?? [] }));
|
|
79
|
+
return {
|
|
80
|
+
mode: "resume_loop",
|
|
81
|
+
round: maxRound + 1,
|
|
82
|
+
reviewInput: lastRound.reviewInput ?? "",
|
|
83
|
+
priorRounds,
|
|
84
|
+
priorUsage: lastRound.usage ?? buildDone.usage ?? EMPTY_RUN_USAGE,
|
|
85
|
+
...handle(runStart),
|
|
86
|
+
ticket,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { mode: "fresh" };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** The record carrying the highest `round`. */
|
|
94
|
+
function maxByRound(records) {
|
|
95
|
+
return records.reduce((a, b) => ((b.round ?? 0) >= (a.round ?? 0) ? b : a));
|
|
96
|
+
}
|
|
97
|
+
/** Pull the optional backend handle off the run_start record (omit undefined keys). */
|
|
98
|
+
function handle(runStart) {
|
|
99
|
+
const out = {};
|
|
100
|
+
if (runStart.runId)
|
|
101
|
+
out.runId = runStart.runId;
|
|
102
|
+
if (runStart.workItemId)
|
|
103
|
+
out.workItemId = runStart.workItemId;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=resume.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /go run registry (spec §3b — detached-by-default).
|
|
3
|
+
*
|
|
4
|
+
* Detached runs outlive the prompt that started them, so /go needs one place
|
|
5
|
+
* that answers "what is running / finished / interrupted?": a module-scoped
|
|
6
|
+
* IN-MEMORY registry (the live truth for THIS process: in-flight guard,
|
|
7
|
+
* MAX_CONCURRENT_RUNS, the completion promise) mirrored to an append-only
|
|
8
|
+
* NDJSON file at `~/.yagni-code/registry.ndjson` (dir 0700, file 0600 —
|
|
9
|
+
* mirrors checkpoint.ts) so a LATER session can list finished runs, spot an
|
|
10
|
+
* interrupted one, and resume into its worktree.
|
|
11
|
+
*
|
|
12
|
+
* The mirror is append-only and torn-line-tolerant (same crash model as
|
|
13
|
+
* checkpoint.ts): every state change appends the FULL row again and readers
|
|
14
|
+
* fold last-write-wins by runId, so a torn final write costs at most the last
|
|
15
|
+
* update, never the file. All writes are fail-soft — registry bookkeeping must
|
|
16
|
+
* never break /go.
|
|
17
|
+
*
|
|
18
|
+
* Liveness is decided by the PURE {@link classifyRunLiveness}: a non-terminal
|
|
19
|
+
* row is `in_flight` when this process holds it, otherwise the run's checkpoint
|
|
20
|
+
* journal decides — recent activity (< 30 min) means it is probably running in
|
|
21
|
+
* another process (`active_elsewhere`), stale means `interrupted` (a resume
|
|
22
|
+
* candidate).
|
|
23
|
+
*/
|
|
24
|
+
import type { CheckpointRecord, StopReason } from "./types.js";
|
|
25
|
+
/** Bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
26
|
+
export declare const MAX_CONCURRENT_RUNS = 3;
|
|
27
|
+
/**
|
|
28
|
+
* A non-terminal row whose journal has been quiet this long is treated as
|
|
29
|
+
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
30
|
+
* comfortably past the 5-min run heartbeat and the server reaper's 30-min
|
|
31
|
+
* staleness window so the two views agree.
|
|
32
|
+
*/
|
|
33
|
+
export declare const JOURNAL_STALE_MS: number;
|
|
34
|
+
/**
|
|
35
|
+
* One registry row (the spec's frozen shape). `status` is `running` until the
|
|
36
|
+
* run reaches a terminal outcome, then the honest stop reason; `cleaned` marks
|
|
37
|
+
* a terminal row whose worktree /go-clean has removed. `runId` is the LOCAL
|
|
38
|
+
* run identity (minted before the backend run row exists — the worktree dir and
|
|
39
|
+
* UI key derive from its first 8 chars), not the backend runs-row id.
|
|
40
|
+
*/
|
|
41
|
+
export interface RunRegistryRow {
|
|
42
|
+
runId: string;
|
|
43
|
+
ticket: string;
|
|
44
|
+
sessionKey: string;
|
|
45
|
+
repo?: string;
|
|
46
|
+
/** Absent for an in-place (--here) run. */
|
|
47
|
+
worktreePath?: string;
|
|
48
|
+
branch?: string;
|
|
49
|
+
status: RunRegistryStatus;
|
|
50
|
+
startedAt: number;
|
|
51
|
+
commitSha?: string;
|
|
52
|
+
prUrl?: string;
|
|
53
|
+
}
|
|
54
|
+
export type RunRegistryStatus = "running" | "cleaned" | StopReason;
|
|
55
|
+
/** Everything except `running` is terminal (the run is not in flight anywhere). */
|
|
56
|
+
export declare function isTerminalStatus(status: RunRegistryStatus): boolean;
|
|
57
|
+
/** How a registry row relates to live execution right now. */
|
|
58
|
+
export type RunLiveness = "in_flight" | "active_elsewhere" | "interrupted" | "terminal";
|
|
59
|
+
/**
|
|
60
|
+
* PURE liveness verdict for one row. `inFlight` is this process's in-memory
|
|
61
|
+
* truth; `lastJournalTs` is the newest checkpoint-journal timestamp for the
|
|
62
|
+
* row's sessionKey (fall back to `startedAt` when the run never journaled).
|
|
63
|
+
*/
|
|
64
|
+
export declare function classifyRunLiveness(row: RunRegistryRow, opts: {
|
|
65
|
+
inFlight: boolean;
|
|
66
|
+
lastJournalTs?: number;
|
|
67
|
+
now: number;
|
|
68
|
+
}): RunLiveness;
|
|
69
|
+
/** The newest checkpoint timestamp in a run's journal (0 when empty). */
|
|
70
|
+
export declare function lastJournalTs(records: CheckpointRecord[]): number;
|
|
71
|
+
/**
|
|
72
|
+
* Fold raw NDJSON mirror lines into rows, last-write-wins by runId, preserving
|
|
73
|
+
* first-seen order. Pure + torn-line-tolerant: unparseable or shape-less lines
|
|
74
|
+
* are dropped (the classic crash artifact), never fatal.
|
|
75
|
+
*/
|
|
76
|
+
export declare function foldRegistryLines(lines: string[]): RunRegistryRow[];
|
|
77
|
+
export declare function _setRegistryHomeForTest(dir: string | null): void;
|
|
78
|
+
/** The append-only NDJSON mirror file. */
|
|
79
|
+
export declare function registryFile(): string;
|
|
80
|
+
/** Where detached run worktrees live (never inside the repo — spec §6). */
|
|
81
|
+
export declare function worktreesDir(): string;
|
|
82
|
+
/** Test seam: clear the in-memory half (simulates a process restart; the mirror survives). */
|
|
83
|
+
export declare function _resetRunRegistryForTest(): void;
|
|
84
|
+
/** Mark a run in-flight in this process and mirror the row. Never throws. */
|
|
85
|
+
export declare function beginRun(row: RunRegistryRow): void;
|
|
86
|
+
/** Attach the detached completion promise to an in-flight run (fail-soft no-op when absent). */
|
|
87
|
+
export declare function trackRunPromise(runId: string, promise: Promise<void>): void;
|
|
88
|
+
/**
|
|
89
|
+
* Settle an in-flight run: merge the terminal patch, drop it from the in-memory
|
|
90
|
+
* active set, and mirror the final row. Safe to call for an unknown runId: the
|
|
91
|
+
* patch lands against the last known mirror row when one exists, or against the
|
|
92
|
+
* patch itself when the caller supplied a full row (the /go-clean path, whose
|
|
93
|
+
* rows may come through an injected registry read).
|
|
94
|
+
*/
|
|
95
|
+
export declare function settleRun(runId: string, patch: Partial<RunRegistryRow> & {
|
|
96
|
+
status: RunRegistryStatus;
|
|
97
|
+
}): void;
|
|
98
|
+
/** How many runs this process currently has in flight. */
|
|
99
|
+
export declare function activeRunCount(): number;
|
|
100
|
+
/** The in-flight row for a ticket in THIS process, if any (the same-ticket guard). */
|
|
101
|
+
export declare function findActiveRunByTicket(ticket: string): RunRegistryRow | undefined;
|
|
102
|
+
/** Is this runId in flight in THIS process? */
|
|
103
|
+
export declare function isRunInFlight(runId: string): boolean;
|
|
104
|
+
/** Test seam: await every in-flight run's completion promise. */
|
|
105
|
+
export declare function _awaitActiveRunsForTest(): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* The merged registry view: the NDJSON mirror folded last-write-wins, with this
|
|
108
|
+
* process's in-memory rows overlaid (they are the fresher truth when a mirror
|
|
109
|
+
* write was lost). Never throws; an unreadable mirror yields the in-memory rows.
|
|
110
|
+
*/
|
|
111
|
+
export declare function loadRegistryRows(): RunRegistryRow[];
|
|
112
|
+
//# sourceMappingURL=runRegistry.d.ts.map
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /go run registry (spec §3b — detached-by-default).
|
|
3
|
+
*
|
|
4
|
+
* Detached runs outlive the prompt that started them, so /go needs one place
|
|
5
|
+
* that answers "what is running / finished / interrupted?": a module-scoped
|
|
6
|
+
* IN-MEMORY registry (the live truth for THIS process: in-flight guard,
|
|
7
|
+
* MAX_CONCURRENT_RUNS, the completion promise) mirrored to an append-only
|
|
8
|
+
* NDJSON file at `~/.yagni-code/registry.ndjson` (dir 0700, file 0600 —
|
|
9
|
+
* mirrors checkpoint.ts) so a LATER session can list finished runs, spot an
|
|
10
|
+
* interrupted one, and resume into its worktree.
|
|
11
|
+
*
|
|
12
|
+
* The mirror is append-only and torn-line-tolerant (same crash model as
|
|
13
|
+
* checkpoint.ts): every state change appends the FULL row again and readers
|
|
14
|
+
* fold last-write-wins by runId, so a torn final write costs at most the last
|
|
15
|
+
* update, never the file. All writes are fail-soft — registry bookkeeping must
|
|
16
|
+
* never break /go.
|
|
17
|
+
*
|
|
18
|
+
* Liveness is decided by the PURE {@link classifyRunLiveness}: a non-terminal
|
|
19
|
+
* row is `in_flight` when this process holds it, otherwise the run's checkpoint
|
|
20
|
+
* journal decides — recent activity (< 30 min) means it is probably running in
|
|
21
|
+
* another process (`active_elsewhere`), stale means `interrupted` (a resume
|
|
22
|
+
* candidate).
|
|
23
|
+
*/
|
|
24
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { codeStateHome } from "../stateHome.js";
|
|
27
|
+
/** Bound on simultaneously in-flight /go runs in one process (spec §3b). */
|
|
28
|
+
export const MAX_CONCURRENT_RUNS = 3;
|
|
29
|
+
/**
|
|
30
|
+
* A non-terminal row whose journal has been quiet this long is treated as
|
|
31
|
+
* INTERRUPTED (its process died) rather than still running elsewhere. Sits
|
|
32
|
+
* comfortably past the 5-min run heartbeat and the server reaper's 30-min
|
|
33
|
+
* staleness window so the two views agree.
|
|
34
|
+
*/
|
|
35
|
+
export const JOURNAL_STALE_MS = 30 * 60_000;
|
|
36
|
+
/** Everything except `running` is terminal (the run is not in flight anywhere). */
|
|
37
|
+
export function isTerminalStatus(status) {
|
|
38
|
+
return status !== "running";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* PURE liveness verdict for one row. `inFlight` is this process's in-memory
|
|
42
|
+
* truth; `lastJournalTs` is the newest checkpoint-journal timestamp for the
|
|
43
|
+
* row's sessionKey (fall back to `startedAt` when the run never journaled).
|
|
44
|
+
*/
|
|
45
|
+
export function classifyRunLiveness(row, opts) {
|
|
46
|
+
if (isTerminalStatus(row.status))
|
|
47
|
+
return "terminal";
|
|
48
|
+
if (opts.inFlight)
|
|
49
|
+
return "in_flight";
|
|
50
|
+
const last = opts.lastJournalTs ?? row.startedAt;
|
|
51
|
+
return opts.now - last > JOURNAL_STALE_MS ? "interrupted" : "active_elsewhere";
|
|
52
|
+
}
|
|
53
|
+
/** The newest checkpoint timestamp in a run's journal (0 when empty). */
|
|
54
|
+
export function lastJournalTs(records) {
|
|
55
|
+
let max = 0;
|
|
56
|
+
for (const r of records) {
|
|
57
|
+
if (typeof r?.ts === "number" && r.ts > max)
|
|
58
|
+
max = r.ts;
|
|
59
|
+
}
|
|
60
|
+
return max;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Fold raw NDJSON mirror lines into rows, last-write-wins by runId, preserving
|
|
64
|
+
* first-seen order. Pure + torn-line-tolerant: unparseable or shape-less lines
|
|
65
|
+
* are dropped (the classic crash artifact), never fatal.
|
|
66
|
+
*/
|
|
67
|
+
export function foldRegistryLines(lines) {
|
|
68
|
+
const byId = new Map();
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
if (!line.trim())
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
const row = JSON.parse(line);
|
|
74
|
+
if (!row || typeof row.runId !== "string" || typeof row.ticket !== "string")
|
|
75
|
+
continue;
|
|
76
|
+
if (typeof row.sessionKey !== "string" || typeof row.status !== "string")
|
|
77
|
+
continue;
|
|
78
|
+
byId.set(row.runId, row);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* torn/garbage line — drop it, keep the rest */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [...byId.values()];
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// State-dir seams (mirrors checkpoint.ts `_setCheckpointHomeForTest`).
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
let homeOverride = null;
|
|
90
|
+
export function _setRegistryHomeForTest(dir) {
|
|
91
|
+
homeOverride = dir;
|
|
92
|
+
}
|
|
93
|
+
function registryHome() {
|
|
94
|
+
return codeStateHome(homeOverride);
|
|
95
|
+
}
|
|
96
|
+
/** The append-only NDJSON mirror file. */
|
|
97
|
+
export function registryFile() {
|
|
98
|
+
return join(registryHome(), "registry.ndjson");
|
|
99
|
+
}
|
|
100
|
+
/** Where detached run worktrees live (never inside the repo — spec §6). */
|
|
101
|
+
export function worktreesDir() {
|
|
102
|
+
return join(registryHome(), "worktrees");
|
|
103
|
+
}
|
|
104
|
+
const active = new Map();
|
|
105
|
+
/** Test seam: clear the in-memory half (simulates a process restart; the mirror survives). */
|
|
106
|
+
export function _resetRunRegistryForTest() {
|
|
107
|
+
active.clear();
|
|
108
|
+
}
|
|
109
|
+
/** Fail-soft append of one full row to the mirror (self-heals a torn previous write). */
|
|
110
|
+
function appendRow(row) {
|
|
111
|
+
try {
|
|
112
|
+
mkdirSync(registryHome(), { recursive: true, mode: 0o700 });
|
|
113
|
+
const file = registryFile();
|
|
114
|
+
let prefix = "";
|
|
115
|
+
try {
|
|
116
|
+
const existing = readFileSync(file);
|
|
117
|
+
if (existing.length > 0 && existing[existing.length - 1] !== 0x0a)
|
|
118
|
+
prefix = "\n";
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* no file yet — nothing to heal */
|
|
122
|
+
}
|
|
123
|
+
appendFileSync(file, `${prefix}${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* fail-soft: registry bookkeeping must never break /go */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Mark a run in-flight in this process and mirror the row. Never throws. */
|
|
130
|
+
export function beginRun(row) {
|
|
131
|
+
active.set(row.runId, { row });
|
|
132
|
+
appendRow(row);
|
|
133
|
+
}
|
|
134
|
+
/** Attach the detached completion promise to an in-flight run (fail-soft no-op when absent). */
|
|
135
|
+
export function trackRunPromise(runId, promise) {
|
|
136
|
+
const entry = active.get(runId);
|
|
137
|
+
if (entry)
|
|
138
|
+
entry.promise = promise;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Settle an in-flight run: merge the terminal patch, drop it from the in-memory
|
|
142
|
+
* active set, and mirror the final row. Safe to call for an unknown runId: the
|
|
143
|
+
* patch lands against the last known mirror row when one exists, or against the
|
|
144
|
+
* patch itself when the caller supplied a full row (the /go-clean path, whose
|
|
145
|
+
* rows may come through an injected registry read).
|
|
146
|
+
*/
|
|
147
|
+
export function settleRun(runId, patch) {
|
|
148
|
+
const entry = active.get(runId);
|
|
149
|
+
const base = entry?.row ??
|
|
150
|
+
loadRegistryRows().find((r) => r.runId === runId) ??
|
|
151
|
+
(isFullRow(patch) ? patch : undefined);
|
|
152
|
+
active.delete(runId);
|
|
153
|
+
if (!base)
|
|
154
|
+
return;
|
|
155
|
+
appendRow({ ...base, ...patch, runId: base.runId ?? runId });
|
|
156
|
+
}
|
|
157
|
+
/** Does a settle patch carry the whole row shape (usable as its own base)? */
|
|
158
|
+
function isFullRow(patch) {
|
|
159
|
+
return (typeof patch.runId === "string" &&
|
|
160
|
+
typeof patch.ticket === "string" &&
|
|
161
|
+
typeof patch.sessionKey === "string" &&
|
|
162
|
+
typeof patch.startedAt === "number");
|
|
163
|
+
}
|
|
164
|
+
/** How many runs this process currently has in flight. */
|
|
165
|
+
export function activeRunCount() {
|
|
166
|
+
return active.size;
|
|
167
|
+
}
|
|
168
|
+
/** The in-flight row for a ticket in THIS process, if any (the same-ticket guard). */
|
|
169
|
+
export function findActiveRunByTicket(ticket) {
|
|
170
|
+
for (const entry of active.values()) {
|
|
171
|
+
if (entry.row.ticket === ticket)
|
|
172
|
+
return entry.row;
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
/** Is this runId in flight in THIS process? */
|
|
177
|
+
export function isRunInFlight(runId) {
|
|
178
|
+
return active.has(runId);
|
|
179
|
+
}
|
|
180
|
+
/** Test seam: await every in-flight run's completion promise. */
|
|
181
|
+
export async function _awaitActiveRunsForTest() {
|
|
182
|
+
await Promise.all([...active.values()].map((e) => e.promise ?? Promise.resolve()));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The merged registry view: the NDJSON mirror folded last-write-wins, with this
|
|
186
|
+
* process's in-memory rows overlaid (they are the fresher truth when a mirror
|
|
187
|
+
* write was lost). Never throws; an unreadable mirror yields the in-memory rows.
|
|
188
|
+
*/
|
|
189
|
+
export function loadRegistryRows() {
|
|
190
|
+
let rows = [];
|
|
191
|
+
try {
|
|
192
|
+
rows = foldRegistryLines(readFileSync(registryFile(), "utf8").split("\n"));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
rows = [];
|
|
196
|
+
}
|
|
197
|
+
const byId = new Map(rows.map((r) => [r.runId, r]));
|
|
198
|
+
for (const entry of active.values())
|
|
199
|
+
byId.set(entry.row.runId, entry.row);
|
|
200
|
+
return [...byId.values()];
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=runRegistry.js.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/go` run-session adapter.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the device-token `/api/yagni-code/runs*` endpoints so a YAGNI Code
|
|
5
|
+
* pipeline run becomes a tracked `runs` row visible on the Work page: open the
|
|
6
|
+
* run (`start`), stream each pipeline stage (`stage`), bank an in-loop
|
|
7
|
+
* correction (`correction`), and close it with an honest outcome (`finish`).
|
|
8
|
+
*
|
|
9
|
+
* CRITICAL invariant: recording must NEVER break the pipeline. Every call is
|
|
10
|
+
* fail-soft — it never throws. `start` resolves to a typed outcome that
|
|
11
|
+
* distinguishes a tracked run from an untracked one (flag off, unauthorized, or
|
|
12
|
+
* the backend unreachable/erroring) so the caller can surface a notice instead
|
|
13
|
+
* of failing silently; the post-start verbs (`stage`/`correction`/`finish`) stay
|
|
14
|
+
* silent no-ops. `/go` runs to completion regardless of the recording outcome.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the fetch-seam shape of {@link makeRecordEngineeringContextTool}
|
|
17
|
+
* (`baseUrl` + injectable `getToken` + `fetchImpl`) so it is unit-testable
|
|
18
|
+
* without a network and reuses the same auth convention.
|
|
19
|
+
*/
|
|
20
|
+
/** Options for {@link makeRunSession}. */
|
|
21
|
+
export interface RunSessionOptions {
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
getToken: () => string | undefined;
|
|
24
|
+
fetchImpl?: typeof fetch;
|
|
25
|
+
/**
|
|
26
|
+
* Backoff sleep + jitter seams, threaded into {@link resilientFetch} so the
|
|
27
|
+
* retry path is deterministic and instant under test. Default to the real
|
|
28
|
+
* timer / Math.random in production.
|
|
29
|
+
*/
|
|
30
|
+
sleep?: (ms: number) => Promise<void>;
|
|
31
|
+
random?: () => number;
|
|
32
|
+
/**
|
|
33
|
+
* Opt-in debug sink for diagnosing a recording failure (the fail-soft path
|
|
34
|
+
* otherwise collapses 402/5xx/2xx-bad-body/unreachable into one silent
|
|
35
|
+
* "error"). Only ever receives a token-free line: the path, the numeric HTTP
|
|
36
|
+
* status, a capped slice of the SERVER response body, or a network error
|
|
37
|
+
* message. NEVER the request headers (the bearer token) or the request body.
|
|
38
|
+
* Injected for deterministic tests; defaults to an env-gated stderr writer.
|
|
39
|
+
*/
|
|
40
|
+
debug?: (line: string) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Environment seam for the debug gate, matching `config.ts`/`verify.ts`. The
|
|
43
|
+
* debug sink only fires when `YAGNI_CODE_DEBUG` is set here (default
|
|
44
|
+
* `process.env`), so existing callers that inject neither `debug` nor `env`
|
|
45
|
+
* stay silent unless the operator opts in.
|
|
46
|
+
*/
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
48
|
+
}
|
|
49
|
+
/** The handle returned by a successful `start` (or supplied via `bind`). */
|
|
50
|
+
export interface RunSessionHandle {
|
|
51
|
+
runId: string;
|
|
52
|
+
workItemId: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The result of a successful `start`: the handle plus the server's governance
|
|
56
|
+
* verdict (`gated` = the work item needs approval in the app before it shows as
|
|
57
|
+
* executing, so /go should warn rather than silently proceed).
|
|
58
|
+
*/
|
|
59
|
+
export interface RunStartResult extends RunSessionHandle {
|
|
60
|
+
gated: boolean;
|
|
61
|
+
gatedReason?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Why a run could not be recorded, derived from the endpoint's HTTP status so
|
|
65
|
+
* the caller can show an actionable notice:
|
|
66
|
+
* - `disabled` — 404: the `yagni_code.work_tracking` flag is off for this
|
|
67
|
+
* workspace (the write endpoints 404 to hide themselves).
|
|
68
|
+
* - `unauthorized` — 401/403: the device token is missing/expired or lacks the
|
|
69
|
+
* `yagni_code:write` scope (re-run `yagni login`).
|
|
70
|
+
* - `error` — anything else: an over-budget 402, a 5xx, a 2xx with an
|
|
71
|
+
* unrecognized body, or an unreachable backend / thrown
|
|
72
|
+
* network error.
|
|
73
|
+
*/
|
|
74
|
+
export type RunTrackingFailure = "disabled" | "unauthorized" | "error";
|
|
75
|
+
/**
|
|
76
|
+
* The outcome of opening a run. `tracked: true` carries the handle + gate
|
|
77
|
+
* verdict; `tracked: false` carries the reason so `/go` can warn that this run
|
|
78
|
+
* won't appear on the Work page. `start` never returns null and never throws.
|
|
79
|
+
*/
|
|
80
|
+
export type RunStartOutcome = ({
|
|
81
|
+
tracked: true;
|
|
82
|
+
} & RunStartResult) | {
|
|
83
|
+
tracked: false;
|
|
84
|
+
reason: RunTrackingFailure;
|
|
85
|
+
};
|
|
86
|
+
/** Arguments to open a run. */
|
|
87
|
+
export interface StartArgs {
|
|
88
|
+
arg: string;
|
|
89
|
+
repo?: string;
|
|
90
|
+
branch?: string;
|
|
91
|
+
}
|
|
92
|
+
/** A pipeline stage boundary event. */
|
|
93
|
+
export interface StageArgs {
|
|
94
|
+
stage: string;
|
|
95
|
+
phase: "start" | "finish";
|
|
96
|
+
round?: number;
|
|
97
|
+
findings?: number;
|
|
98
|
+
}
|
|
99
|
+
/** An in-loop correction captured during the run. */
|
|
100
|
+
export interface CorrectionArgs {
|
|
101
|
+
stage?: string;
|
|
102
|
+
question: string;
|
|
103
|
+
decision: string;
|
|
104
|
+
rationale?: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The /go PLAN, recorded onto the work item (a `plan_recorded` activity beat) so
|
|
108
|
+
* it is clear what the run set out to do. `body` is the plan markdown; the server
|
|
109
|
+
* scrubs secrets + caps it before persisting.
|
|
110
|
+
*/
|
|
111
|
+
export interface RecordPlanArgs {
|
|
112
|
+
stage: string;
|
|
113
|
+
body: string;
|
|
114
|
+
}
|
|
115
|
+
/** How a run ended; maps to the run-status enum server-side. */
|
|
116
|
+
export interface FinishArgs {
|
|
117
|
+
outcome: "completed" | "stopped" | "failed";
|
|
118
|
+
stopReason?: string;
|
|
119
|
+
prUrl?: string;
|
|
120
|
+
prNumber?: number;
|
|
121
|
+
branch?: string;
|
|
122
|
+
commitSha?: string;
|
|
123
|
+
rounds?: number;
|
|
124
|
+
/**
|
|
125
|
+
* Short "what was done" recap; the server records it as a `run_outcome`
|
|
126
|
+
* activity (the end-of-run beat on the Work-page timeline). Optional + additive:
|
|
127
|
+
* `finish` already spreads the whole args object, so it flows through untouched.
|
|
128
|
+
*/
|
|
129
|
+
outcomeSummary?: string;
|
|
130
|
+
}
|
|
131
|
+
/** The run-session object returned by {@link makeRunSession}. */
|
|
132
|
+
export interface RunSession {
|
|
133
|
+
/** Adopt an externally-known handle (used by tests + cloud callers). */
|
|
134
|
+
bind(h: RunSessionHandle): void;
|
|
135
|
+
/** Open the run; resolves to a tracked outcome (handle + gate verdict) or an untracked reason. Never throws. */
|
|
136
|
+
start(b: StartArgs): Promise<RunStartOutcome>;
|
|
137
|
+
/** Stream a stage boundary; no-op until a handle exists. */
|
|
138
|
+
stage(b: StageArgs): Promise<void>;
|
|
139
|
+
/** Bank an in-loop correction; no-op until a handle exists. */
|
|
140
|
+
correction(b: CorrectionArgs): Promise<void>;
|
|
141
|
+
/** Record the /go plan onto the work item; no-op until a handle exists. */
|
|
142
|
+
recordPlan(b: RecordPlanArgs): Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* Bump the run's server-side last-activity heartbeat so a long-but-alive run is
|
|
145
|
+
* not reaped as stale. No-op until a handle exists; never throws.
|
|
146
|
+
*/
|
|
147
|
+
heartbeat(): Promise<void>;
|
|
148
|
+
/** Close the run with an outcome; no-op until a handle exists. */
|
|
149
|
+
finish(b: FinishArgs): Promise<void>;
|
|
150
|
+
}
|
|
151
|
+
export declare function makeRunSession(opts: RunSessionOptions): RunSession;
|
|
152
|
+
//# sourceMappingURL=runSession.d.ts.map
|