ramstack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/agents/comment-sicko.md +32 -0
- package/agents/poteto-agent.md +9 -0
- package/cli/rstack.mjs +539 -0
- package/package.json +29 -0
- package/skills/architect/README.md +25 -0
- package/skills/architect/SKILL.md +83 -0
- package/skills/architect/agents/openai.yaml +5 -0
- package/skills/architect/references/design-red-flags.md +33 -0
- package/skills/architect/references/rationale-template.md +35 -0
- package/skills/architect/references/runner-prompt.md +20 -0
- package/skills/arena/README.md +21 -0
- package/skills/arena/SKILL.md +71 -0
- package/skills/arena/agents/openai.yaml +5 -0
- package/skills/automate-me/README.md +21 -0
- package/skills/automate-me/SKILL.md +109 -0
- package/skills/automate-me/agents/openai.yaml +5 -0
- package/skills/blast-radius/README.md +21 -0
- package/skills/blast-radius/SKILL.md +50 -0
- package/skills/blast-radius/agents/openai.yaml +5 -0
- package/skills/bro/README.md +16 -0
- package/skills/bro/SKILL.md +7 -0
- package/skills/bro/agents/openai.yaml +5 -0
- package/skills/create-verification-skill/README.md +22 -0
- package/skills/create-verification-skill/SKILL.md +44 -0
- package/skills/create-verification-skill/agents/openai.yaml +5 -0
- package/skills/create-verification-skill/references/feature-map-example/README.md +47 -0
- package/skills/create-verification-skill/references/feature-map-example/create-note.md +39 -0
- package/skills/create-verification-skill/references/feature-map-example/search.md +45 -0
- package/skills/figure-it-out/README.md +22 -0
- package/skills/figure-it-out/SKILL.md +55 -0
- package/skills/figure-it-out/agents/openai.yaml +5 -0
- package/skills/how/README.md +25 -0
- package/skills/how/SKILL.md +134 -0
- package/skills/how/agents/openai.yaml +3 -0
- package/skills/how/references/critic-prompt.md +59 -0
- package/skills/how/references/critique-rubric.md +58 -0
- package/skills/how/references/explainer-prompt.md +55 -0
- package/skills/how/references/explorer-prompt.md +52 -0
- package/skills/interrogate/README.md +21 -0
- package/skills/interrogate/SKILL.md +112 -0
- package/skills/interrogate/agents/openai.yaml +5 -0
- package/skills/interrogate/references/code-quality-review.md +47 -0
- package/skills/interrogate/references/lead-judgment.md +58 -0
- package/skills/interrogate/references/reviewer-prompt.md +72 -0
- package/skills/interrogate/references/rubric.md +77 -0
- package/skills/maintain-verification-skill/README.md +21 -0
- package/skills/maintain-verification-skill/SKILL.md +39 -0
- package/skills/maintain-verification-skill/agents/openai.yaml +5 -0
- package/skills/no-comments/README.md +22 -0
- package/skills/no-comments/SKILL.md +24 -0
- package/skills/no-comments/agents/openai.yaml +5 -0
- package/skills/poteto-mode/README.md +26 -0
- package/skills/poteto-mode/SKILL.md +140 -0
- package/skills/poteto-mode/agents/openai.yaml +5 -0
- package/skills/poteto-mode/playbooks/authoring-a-skill.md +13 -0
- package/skills/poteto-mode/playbooks/autonomous-run.md +13 -0
- package/skills/poteto-mode/playbooks/autopilot-full.md +13 -0
- package/skills/poteto-mode/playbooks/autopilot-stack.md +16 -0
- package/skills/poteto-mode/playbooks/babysit.md +27 -0
- package/skills/poteto-mode/playbooks/bug-fix.md +17 -0
- package/skills/poteto-mode/playbooks/eval.md +27 -0
- package/skills/poteto-mode/playbooks/feature.md +21 -0
- package/skills/poteto-mode/playbooks/hillclimb.md +21 -0
- package/skills/poteto-mode/playbooks/investigation.md +14 -0
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +3 -0
- package/skills/poteto-mode/playbooks/opening-a-pr.md +11 -0
- package/skills/poteto-mode/playbooks/orchestrate.md +113 -0
- package/skills/poteto-mode/playbooks/pause-safely.md +10 -0
- package/skills/poteto-mode/playbooks/perf-issue.md +24 -0
- package/skills/poteto-mode/playbooks/prototype.md +14 -0
- package/skills/poteto-mode/playbooks/refactoring.md +16 -0
- package/skills/poteto-mode/playbooks/runtime-forensics.md +11 -0
- package/skills/poteto-mode/playbooks/session-pickup.md +13 -0
- package/skills/poteto-mode/playbooks/shipping.md +20 -0
- package/skills/poteto-mode/playbooks/trace-forensics.md +14 -0
- package/skills/poteto-mode/playbooks/visual-parity.md +11 -0
- package/skills/poteto-mode/playbooks/worktree-cleanup.md +14 -0
- package/skills/poteto-mode/references/bugbot-triage.md +142 -0
- package/skills/poteto-mode/references/plan.md +105 -0
- package/skills/poteto-mode/scripts/bootstrap.ts +62 -0
- package/skills/poteto-mode/scripts/bun.lock +67 -0
- package/skills/poteto-mode/scripts/orch/orch.test.ts +634 -0
- package/skills/poteto-mode/scripts/orch/orch.ts +578 -0
- package/skills/poteto-mode/scripts/orch/store.ts +1607 -0
- package/skills/poteto-mode/scripts/package.json +16 -0
- package/skills/poteto-mode/scripts/watch-pr/cli.test.ts +224 -0
- package/skills/poteto-mode/scripts/watch-pr/cli.ts +223 -0
- package/skills/poteto-mode/scripts/watch-pr/fakes.test-helper.ts +118 -0
- package/skills/poteto-mode/scripts/watch-pr/github.test.ts +306 -0
- package/skills/poteto-mode/scripts/watch-pr/github.ts +699 -0
- package/skills/poteto-mode/scripts/watch-pr/policy.test.ts +420 -0
- package/skills/poteto-mode/scripts/watch-pr/policy.ts +832 -0
- package/skills/poteto-mode/scripts/watch-pr/render.ts +169 -0
- package/skills/poteto-mode/scripts/watch-pr/tsconfig.json +13 -0
- package/skills/poteto-mode/scripts/watch-pr/types.compile.ts +93 -0
- package/skills/poteto-mode/scripts/watch-pr/types.ts +401 -0
- package/skills/poteto-mode/scripts/watch-pr/watch-pr +6 -0
- package/skills/poteto-mode/scripts/worktree-audit.sh +86 -0
- package/skills/principle-boundary-discipline/README.md +17 -0
- package/skills/principle-boundary-discipline/SKILL.md +34 -0
- package/skills/principle-boundary-discipline/agents/openai.yaml +5 -0
- package/skills/principle-build-the-lever/README.md +17 -0
- package/skills/principle-build-the-lever/SKILL.md +23 -0
- package/skills/principle-build-the-lever/agents/openai.yaml +5 -0
- package/skills/principle-encode-lessons-in-structure/README.md +17 -0
- package/skills/principle-encode-lessons-in-structure/SKILL.md +31 -0
- package/skills/principle-encode-lessons-in-structure/agents/openai.yaml +5 -0
- package/skills/principle-exhaust-the-design-space/README.md +17 -0
- package/skills/principle-exhaust-the-design-space/SKILL.md +21 -0
- package/skills/principle-exhaust-the-design-space/agents/openai.yaml +5 -0
- package/skills/principle-experience-first/README.md +17 -0
- package/skills/principle-experience-first/SKILL.md +19 -0
- package/skills/principle-experience-first/agents/openai.yaml +5 -0
- package/skills/principle-fix-root-causes/README.md +17 -0
- package/skills/principle-fix-root-causes/SKILL.md +23 -0
- package/skills/principle-fix-root-causes/agents/openai.yaml +5 -0
- package/skills/principle-foundational-thinking/README.md +17 -0
- package/skills/principle-foundational-thinking/SKILL.md +21 -0
- package/skills/principle-foundational-thinking/agents/openai.yaml +5 -0
- package/skills/principle-guard-the-context-window/README.md +17 -0
- package/skills/principle-guard-the-context-window/SKILL.md +17 -0
- package/skills/principle-guard-the-context-window/agents/openai.yaml +5 -0
- package/skills/principle-laziness-protocol/README.md +17 -0
- package/skills/principle-laziness-protocol/SKILL.md +18 -0
- package/skills/principle-laziness-protocol/agents/openai.yaml +5 -0
- package/skills/principle-make-operations-idempotent/README.md +17 -0
- package/skills/principle-make-operations-idempotent/SKILL.md +24 -0
- package/skills/principle-make-operations-idempotent/agents/openai.yaml +5 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis/README.md +17 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +22 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis/agents/openai.yaml +5 -0
- package/skills/principle-minimize-reader-load/README.md +17 -0
- package/skills/principle-minimize-reader-load/SKILL.md +23 -0
- package/skills/principle-minimize-reader-load/agents/openai.yaml +5 -0
- package/skills/principle-model-the-domain/README.md +17 -0
- package/skills/principle-model-the-domain/SKILL.md +26 -0
- package/skills/principle-model-the-domain/agents/openai.yaml +5 -0
- package/skills/principle-never-block-on-the-human/README.md +16 -0
- package/skills/principle-never-block-on-the-human/SKILL.md +23 -0
- package/skills/principle-never-block-on-the-human/agents/openai.yaml +5 -0
- package/skills/principle-outcome-oriented-execution/README.md +16 -0
- package/skills/principle-outcome-oriented-execution/SKILL.md +22 -0
- package/skills/principle-outcome-oriented-execution/agents/openai.yaml +5 -0
- package/skills/principle-prove-it-works/README.md +18 -0
- package/skills/principle-prove-it-works/SKILL.md +33 -0
- package/skills/principle-prove-it-works/agents/openai.yaml +5 -0
- package/skills/principle-redesign-from-first-principles/README.md +16 -0
- package/skills/principle-redesign-from-first-principles/SKILL.md +16 -0
- package/skills/principle-redesign-from-first-principles/agents/openai.yaml +5 -0
- package/skills/principle-separate-before-serializing-shared-state/README.md +16 -0
- package/skills/principle-separate-before-serializing-shared-state/SKILL.md +16 -0
- package/skills/principle-separate-before-serializing-shared-state/agents/openai.yaml +5 -0
- package/skills/principle-sequence-verifiable-units/README.md +18 -0
- package/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/skills/principle-sequence-verifiable-units/agents/openai.yaml +5 -0
- package/skills/principle-subtract-before-you-add/README.md +17 -0
- package/skills/principle-subtract-before-you-add/SKILL.md +22 -0
- package/skills/principle-subtract-before-you-add/agents/openai.yaml +5 -0
- package/skills/principle-type-system-discipline/README.md +19 -0
- package/skills/principle-type-system-discipline/SKILL.md +31 -0
- package/skills/principle-type-system-discipline/agents/openai.yaml +5 -0
- package/skills/recall/README.md +23 -0
- package/skills/recall/SKILL.md +35 -0
- package/skills/recall/agents/openai.yaml +5 -0
- package/skills/reflect/README.md +24 -0
- package/skills/reflect/SKILL.md +77 -0
- package/skills/reflect/agents/openai.yaml +5 -0
- package/skills/reflect/references/divergent-reviewer.md +43 -0
- package/skills/reflect/references/judgment-reviewer.md +42 -0
- package/skills/reflect/references/synthesizer.md +56 -0
- package/skills/reflect/references/tooling-reviewer.md +57 -0
- package/skills/setup-rstack/README.md +25 -0
- package/skills/setup-rstack/SKILL.md +81 -0
- package/skills/setup-rstack/agents/openai.yaml +3 -0
- package/skills/show-me-your-work/README.md +23 -0
- package/skills/show-me-your-work/SKILL.md +82 -0
- package/skills/show-me-your-work/agents/openai.yaml +5 -0
- package/skills/show-me-your-work/references/decision-log-template.tsv +1 -0
- package/skills/show-me-your-work/scripts/log.sh +40 -0
- package/skills/swarm/README.md +21 -0
- package/skills/swarm/SKILL.md +46 -0
- package/skills/swarm/agents/openai.yaml +5 -0
- package/skills/tdd/README.md +21 -0
- package/skills/tdd/SKILL.md +44 -0
- package/skills/tdd/agents/openai.yaml +5 -0
- package/skills/teach/README.md +22 -0
- package/skills/teach/SKILL.md +21 -0
- package/skills/teach/agents/openai.yaml +5 -0
- package/skills/technical-writing/README.md +23 -0
- package/skills/technical-writing/SKILL.md +130 -0
- package/skills/technical-writing/agents/openai.yaml +5 -0
- package/skills/typescript-best-practices/README.md +22 -0
- package/skills/typescript-best-practices/SKILL.md +28 -0
- package/skills/typescript-best-practices/agents/openai.yaml +3 -0
- package/skills/typescript-best-practices/references/patterns.md +292 -0
- package/skills/unslop/README.md +21 -0
- package/skills/unslop/SKILL.md +80 -0
- package/skills/unslop/agents/openai.yaml +3 -0
- package/skills/why/README.md +26 -0
- package/skills/why/SKILL.md +229 -0
- package/skills/why/agents/openai.yaml +3 -0
- package/skills/why/references/epistemics.md +144 -0
- package/skills/why/references/investigator-prompt.md +103 -0
- package/skills/why/references/source-playbook.md +17 -0
- package/skills/why/references/sources/code-archaeology.md +88 -0
- package/skills/why/references/sources/databricks.md +70 -0
- package/skills/why/references/sources/datadog.md +99 -0
- package/skills/why/references/sources/incident-postmortem.md +15 -0
- package/skills/why/references/sources/linear.md +48 -0
- package/skills/why/references/sources/notion.md +55 -0
- package/skills/why/references/sources/sentry.md +100 -0
- package/skills/why/references/sources/slack.md +54 -0
- package/skills/why/references/synthesizer-prompt.md +135 -0
|
@@ -0,0 +1,1607 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { Dirent } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
access,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
unlink,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
const UNIT_HEADER = "id\ttrack\tstate\tbranch\tpr\tsha\tbrief";
|
|
18
|
+
const LEDGER_HEADER = "pr\tsha\tverdict\tevidence\tverifier\tts";
|
|
19
|
+
const LOCK_FILE = ".orch.lock";
|
|
20
|
+
|
|
21
|
+
export type Verdict =
|
|
22
|
+
| "live-ui-verified"
|
|
23
|
+
| "unit-test-verified"
|
|
24
|
+
| "type-check-only"
|
|
25
|
+
| "verifier-blocked"
|
|
26
|
+
| "verifier-failed";
|
|
27
|
+
|
|
28
|
+
export interface Unit {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly track: string;
|
|
31
|
+
readonly state: string;
|
|
32
|
+
readonly branch: string;
|
|
33
|
+
readonly pr: string;
|
|
34
|
+
readonly sha: string;
|
|
35
|
+
readonly brief: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LedgerEntry {
|
|
39
|
+
readonly pr: string;
|
|
40
|
+
readonly sha: string;
|
|
41
|
+
readonly verdict: Verdict;
|
|
42
|
+
readonly evidence: string;
|
|
43
|
+
readonly verifier: string;
|
|
44
|
+
readonly ts: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InboxPointer {
|
|
48
|
+
readonly ts: string;
|
|
49
|
+
readonly agent: string;
|
|
50
|
+
readonly unit: string;
|
|
51
|
+
readonly status: string;
|
|
52
|
+
readonly report: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface InboxPushResult {
|
|
56
|
+
readonly pointer: InboxPointer;
|
|
57
|
+
readonly filename: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OpenGate {
|
|
61
|
+
readonly kind: "open";
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly question: string;
|
|
64
|
+
readonly options: string;
|
|
65
|
+
readonly defaultAnswer: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ResolvedGate {
|
|
69
|
+
readonly kind: "resolved";
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly question: string;
|
|
72
|
+
readonly options: string;
|
|
73
|
+
readonly defaultAnswer: string;
|
|
74
|
+
readonly answer: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type Gate = OpenGate | ResolvedGate;
|
|
78
|
+
|
|
79
|
+
export type FrontierPrState = "OPEN" | "MERGED" | "CLOSED";
|
|
80
|
+
|
|
81
|
+
export interface FrontierPr {
|
|
82
|
+
readonly pr: number;
|
|
83
|
+
readonly branches: string;
|
|
84
|
+
readonly sha: string;
|
|
85
|
+
readonly state: FrontierPrState;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface Frontier {
|
|
89
|
+
readonly generation: number;
|
|
90
|
+
readonly prs: readonly FrontierPr[];
|
|
91
|
+
readonly lowestUnmerged: number | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface StandingLine {
|
|
95
|
+
readonly number: number;
|
|
96
|
+
readonly line: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type Counts = Readonly<Record<string, number>>;
|
|
100
|
+
|
|
101
|
+
export interface StatusSummary {
|
|
102
|
+
readonly unitStates: Counts;
|
|
103
|
+
readonly ledgerVerdicts: Counts;
|
|
104
|
+
readonly frontierGeneration: number;
|
|
105
|
+
readonly openGateIds: readonly string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface StatusReport {
|
|
109
|
+
readonly units: readonly Unit[];
|
|
110
|
+
readonly ledger: readonly LedgerEntry[];
|
|
111
|
+
readonly frontier: Frontier;
|
|
112
|
+
readonly gates: readonly Gate[];
|
|
113
|
+
readonly summary: StatusSummary;
|
|
114
|
+
readonly changed: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface AddUnitParams {
|
|
118
|
+
readonly id: string;
|
|
119
|
+
readonly track: string;
|
|
120
|
+
readonly brief?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SetUnitParams {
|
|
124
|
+
readonly id: string;
|
|
125
|
+
readonly state: string;
|
|
126
|
+
readonly branch?: string;
|
|
127
|
+
readonly pr?: number;
|
|
128
|
+
readonly sha?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ListUnitsParams {
|
|
132
|
+
readonly state?: string;
|
|
133
|
+
readonly track?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RecordLedgerParams {
|
|
137
|
+
readonly pr: number;
|
|
138
|
+
readonly sha: string;
|
|
139
|
+
readonly verdict: Verdict;
|
|
140
|
+
readonly evidence: string;
|
|
141
|
+
readonly verifier?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface CheckLedgerParams {
|
|
145
|
+
readonly pr: number;
|
|
146
|
+
readonly sha: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface PushInboxParams {
|
|
150
|
+
readonly agent: string;
|
|
151
|
+
readonly unit: string;
|
|
152
|
+
readonly status: string;
|
|
153
|
+
readonly report?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ParkGateParams {
|
|
157
|
+
readonly id: string;
|
|
158
|
+
readonly question: string;
|
|
159
|
+
readonly options: string;
|
|
160
|
+
readonly defaultAnswer: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ResolveGateParams {
|
|
164
|
+
readonly id: string;
|
|
165
|
+
readonly answer: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface SetFrontierParams {
|
|
169
|
+
readonly repo: string;
|
|
170
|
+
readonly prs?: readonly number[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface AddStandingParams {
|
|
174
|
+
readonly line: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface OpenStoreOptions {
|
|
178
|
+
readonly force?: boolean;
|
|
179
|
+
readonly onLockStolen?: (holder: string) => void;
|
|
180
|
+
readonly onStaleLock?: (holder: string) => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface Store {
|
|
184
|
+
readonly units: {
|
|
185
|
+
readonly add: (params: AddUnitParams) => Promise<Unit>;
|
|
186
|
+
readonly set: (params: SetUnitParams) => Promise<Unit>;
|
|
187
|
+
readonly get: (id: string) => Promise<Unit>;
|
|
188
|
+
readonly list: (params?: ListUnitsParams) => Promise<readonly Unit[]>;
|
|
189
|
+
readonly counts: () => Promise<Counts>;
|
|
190
|
+
};
|
|
191
|
+
readonly ledger: {
|
|
192
|
+
readonly record: (params: RecordLedgerParams) => Promise<LedgerEntry>;
|
|
193
|
+
readonly check: (params: CheckLedgerParams) => Promise<LedgerEntry>;
|
|
194
|
+
readonly summary: () => Promise<Counts>;
|
|
195
|
+
};
|
|
196
|
+
readonly inbox: {
|
|
197
|
+
readonly push: (params: PushInboxParams) => Promise<InboxPushResult>;
|
|
198
|
+
readonly drain: () => Promise<readonly InboxPointer[]>;
|
|
199
|
+
readonly peek: () => Promise<readonly InboxPointer[]>;
|
|
200
|
+
readonly count: () => Promise<number>;
|
|
201
|
+
};
|
|
202
|
+
readonly gates: {
|
|
203
|
+
readonly park: (params: ParkGateParams) => Promise<OpenGate>;
|
|
204
|
+
readonly list: () => Promise<readonly OpenGate[]>;
|
|
205
|
+
readonly resolve: (params: ResolveGateParams) => Promise<ResolvedGate>;
|
|
206
|
+
};
|
|
207
|
+
readonly frontier: {
|
|
208
|
+
readonly set: (params: SetFrontierParams) => Promise<Frontier>;
|
|
209
|
+
readonly show: () => Promise<Frontier>;
|
|
210
|
+
};
|
|
211
|
+
readonly standing: {
|
|
212
|
+
readonly show: () => Promise<readonly StandingLine[]>;
|
|
213
|
+
readonly add: (params: AddStandingParams) => Promise<StandingLine>;
|
|
214
|
+
};
|
|
215
|
+
readonly status: {
|
|
216
|
+
readonly render: () => Promise<StatusReport>;
|
|
217
|
+
};
|
|
218
|
+
readonly init: () => Promise<{ readonly store: string }>;
|
|
219
|
+
readonly close: () => Promise<void>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface NotFoundOutput {
|
|
223
|
+
readonly compact: string;
|
|
224
|
+
readonly json: unknown;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export class UserError extends Error {}
|
|
228
|
+
export class UsageError extends UserError {}
|
|
229
|
+
export class NotFoundError extends UserError {
|
|
230
|
+
public constructor(
|
|
231
|
+
message: string,
|
|
232
|
+
public readonly output?: NotFoundOutput
|
|
233
|
+
) {
|
|
234
|
+
super(message);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function errorCode(error: unknown): string | null {
|
|
239
|
+
if (
|
|
240
|
+
error !== null &&
|
|
241
|
+
typeof error === "object" &&
|
|
242
|
+
"code" in error &&
|
|
243
|
+
typeof error.code === "string"
|
|
244
|
+
) {
|
|
245
|
+
return error.code;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function errorMessage(error: unknown): string {
|
|
251
|
+
return error instanceof Error ? error.message : String(error);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
255
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isUnknownArray(value: unknown): value is readonly unknown[] {
|
|
259
|
+
return Array.isArray(value);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function verdictOrNull(value: string): Verdict | null {
|
|
263
|
+
switch (value) {
|
|
264
|
+
case "live-ui-verified":
|
|
265
|
+
case "unit-test-verified":
|
|
266
|
+
case "type-check-only":
|
|
267
|
+
case "verifier-blocked":
|
|
268
|
+
case "verifier-failed":
|
|
269
|
+
return value;
|
|
270
|
+
default:
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function frontierPrStateOrNull(value: unknown): FrontierPrState | null {
|
|
276
|
+
switch (value) {
|
|
277
|
+
case "OPEN":
|
|
278
|
+
case "MERGED":
|
|
279
|
+
case "CLOSED":
|
|
280
|
+
return value;
|
|
281
|
+
default:
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function parseVerdict(value: string): Verdict {
|
|
287
|
+
const verdict = verdictOrNull(value);
|
|
288
|
+
if (verdict === null) {
|
|
289
|
+
throw new UserError(
|
|
290
|
+
"verdict must be live-ui-verified, unit-test-verified, type-check-only, verifier-blocked, or verifier-failed"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return verdict;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function cleanCell(value: string): string {
|
|
297
|
+
const cleaned = value.replace(/[\t\n\r]/g, " ");
|
|
298
|
+
return /^[=+\-@]/.test(cleaned) ? `'${cleaned}` : cleaned;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function requiredCell(value: string, label: string): string {
|
|
302
|
+
const cleaned = cleanCell(value);
|
|
303
|
+
if (cleaned.trim().length === 0) {
|
|
304
|
+
throw new UserError(`${label} must not be empty`);
|
|
305
|
+
}
|
|
306
|
+
return cleaned;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function requiredLine(value: string, label: string): string {
|
|
310
|
+
const cleaned = value.replace(/[\n\r]/g, " ").trim();
|
|
311
|
+
if (cleaned.length === 0) {
|
|
312
|
+
throw new UserError(`${label} must not be empty`);
|
|
313
|
+
}
|
|
314
|
+
return cleaned;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function positiveInteger(value: number, label: string): number {
|
|
318
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
319
|
+
throw new UserError(`${label} must be a positive integer`);
|
|
320
|
+
}
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function exists(path: string): Promise<boolean> {
|
|
325
|
+
try {
|
|
326
|
+
await access(path);
|
|
327
|
+
return true;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (errorCode(error) === "ENOENT") {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function atomicWrite(path: string, contents: string): Promise<void> {
|
|
337
|
+
const temporary = join(
|
|
338
|
+
dirname(path),
|
|
339
|
+
`.${basename(path)}.${process.pid}.${randomUUID()}.tmp`
|
|
340
|
+
);
|
|
341
|
+
try {
|
|
342
|
+
await writeFile(temporary, contents, { flag: "wx" });
|
|
343
|
+
await rename(temporary, path);
|
|
344
|
+
} finally {
|
|
345
|
+
await rm(temporary, { force: true });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function writeIfMissing(path: string, contents: string): Promise<void> {
|
|
350
|
+
if (!(await exists(path))) {
|
|
351
|
+
await atomicWrite(path, contents);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function requiredFile(path: string): Promise<string> {
|
|
356
|
+
try {
|
|
357
|
+
return await readFile(path, "utf8");
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (errorCode(error) === "ENOENT") {
|
|
360
|
+
throw new UserError(
|
|
361
|
+
`store is not initialized at ${dirname(path)}; run orch init`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function holderIsDead(holder: string): boolean {
|
|
369
|
+
const pid = Number.parseInt(holder, 10);
|
|
370
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== holder) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
process.kill(pid, 0);
|
|
375
|
+
return false;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
return errorCode(error) === "ESRCH";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function acquireLock(
|
|
382
|
+
store: string,
|
|
383
|
+
options: OpenStoreOptions
|
|
384
|
+
): Promise<() => Promise<void>> {
|
|
385
|
+
const path = join(store, LOCK_FILE);
|
|
386
|
+
const pid = String(process.pid);
|
|
387
|
+
const create = async (): Promise<void> => {
|
|
388
|
+
const handle = await open(path, "wx");
|
|
389
|
+
await handle.writeFile(`${pid}\n`);
|
|
390
|
+
await handle.close();
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const takeOver = async (): Promise<void> => {
|
|
394
|
+
await unlink(path);
|
|
395
|
+
try {
|
|
396
|
+
await create();
|
|
397
|
+
} catch (retryError) {
|
|
398
|
+
if (errorCode(retryError) === "EEXIST") {
|
|
399
|
+
const retryHolder =
|
|
400
|
+
(await readFile(path, "utf8")).trim() || "unknown";
|
|
401
|
+
throw new UserError(`store lock held by pid ${retryHolder}`);
|
|
402
|
+
}
|
|
403
|
+
throw retryError;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
await create();
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (errorCode(error) !== "EEXIST") {
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
let holder = "unknown";
|
|
414
|
+
try {
|
|
415
|
+
holder = (await readFile(path, "utf8")).trim() || "unknown";
|
|
416
|
+
} catch {
|
|
417
|
+
holder = "unknown";
|
|
418
|
+
}
|
|
419
|
+
if (holderIsDead(holder)) {
|
|
420
|
+
options.onStaleLock?.(holder);
|
|
421
|
+
await takeOver();
|
|
422
|
+
} else if (options.force) {
|
|
423
|
+
options.onLockStolen?.(holder);
|
|
424
|
+
await takeOver();
|
|
425
|
+
} else {
|
|
426
|
+
throw new UserError(`store lock held by pid ${holder}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return async (): Promise<void> => {
|
|
431
|
+
try {
|
|
432
|
+
if ((await readFile(path, "utf8")).trim() === pid) {
|
|
433
|
+
await unlink(path);
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (errorCode(error) !== "ENOENT") {
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function readTsv(
|
|
444
|
+
path: string,
|
|
445
|
+
header: string,
|
|
446
|
+
width: number
|
|
447
|
+
): Promise<readonly (readonly string[])[]> {
|
|
448
|
+
const lines = (await requiredFile(path)).replace(/\r/g, "").split("\n");
|
|
449
|
+
if (lines.shift() !== header) {
|
|
450
|
+
throw new UserError(`${basename(path)} has an invalid header`);
|
|
451
|
+
}
|
|
452
|
+
return lines
|
|
453
|
+
.filter((value) => value.length > 0)
|
|
454
|
+
.map((value) => {
|
|
455
|
+
const cells = value.split("\t");
|
|
456
|
+
if (cells.length !== width) {
|
|
457
|
+
throw new UserError(`${basename(path)} has a malformed row`);
|
|
458
|
+
}
|
|
459
|
+
return cells;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function writeTsv(
|
|
464
|
+
path: string,
|
|
465
|
+
header: string,
|
|
466
|
+
rows: readonly (readonly string[])[]
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
const body = rows.map((row) => row.map(cleanCell).join("\t")).join("\n");
|
|
469
|
+
await atomicWrite(path, `${header}\n${body}${body.length > 0 ? "\n" : ""}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function readUnits(store: string): Promise<readonly Unit[]> {
|
|
473
|
+
return (await readTsv(join(store, "units.tsv"), UNIT_HEADER, 7)).map(
|
|
474
|
+
(row) => ({
|
|
475
|
+
id: row[0] ?? "",
|
|
476
|
+
track: row[1] ?? "",
|
|
477
|
+
state: row[2] ?? "",
|
|
478
|
+
branch: row[3] ?? "",
|
|
479
|
+
pr: row[4] ?? "",
|
|
480
|
+
sha: row[5] ?? "",
|
|
481
|
+
brief: row[6] ?? "",
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function unitCells(unit: Unit): readonly string[] {
|
|
487
|
+
return [
|
|
488
|
+
unit.id,
|
|
489
|
+
unit.track,
|
|
490
|
+
unit.state,
|
|
491
|
+
unit.branch,
|
|
492
|
+
unit.pr,
|
|
493
|
+
unit.sha,
|
|
494
|
+
unit.brief,
|
|
495
|
+
];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function saveUnits(store: string, rows: readonly Unit[]): Promise<void> {
|
|
499
|
+
await writeTsv(
|
|
500
|
+
join(store, "units.tsv"),
|
|
501
|
+
UNIT_HEADER,
|
|
502
|
+
rows.map(unitCells)
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function readLedger(store: string): Promise<readonly LedgerEntry[]> {
|
|
507
|
+
return (await readTsv(join(store, "ledger.tsv"), LEDGER_HEADER, 6)).map(
|
|
508
|
+
(row) => {
|
|
509
|
+
const rawVerdict = row[2] ?? "";
|
|
510
|
+
const verdict = verdictOrNull(rawVerdict);
|
|
511
|
+
if (verdict === null) {
|
|
512
|
+
throw new UserError(`ledger.tsv has invalid verdict ${rawVerdict}`);
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
pr: row[0] ?? "",
|
|
516
|
+
sha: row[1] ?? "",
|
|
517
|
+
verdict,
|
|
518
|
+
evidence: row[3] ?? "",
|
|
519
|
+
verifier: row[4] ?? "",
|
|
520
|
+
ts: row[5] ?? "",
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function ledgerCells(row: LedgerEntry): readonly string[] {
|
|
527
|
+
return [
|
|
528
|
+
row.pr,
|
|
529
|
+
row.sha,
|
|
530
|
+
row.verdict,
|
|
531
|
+
row.evidence,
|
|
532
|
+
row.verifier,
|
|
533
|
+
row.ts,
|
|
534
|
+
];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function saveLedger(
|
|
538
|
+
store: string,
|
|
539
|
+
rows: readonly LedgerEntry[]
|
|
540
|
+
): Promise<void> {
|
|
541
|
+
await writeTsv(
|
|
542
|
+
join(store, "ledger.tsv"),
|
|
543
|
+
LEDGER_HEADER,
|
|
544
|
+
rows.map(ledgerCells)
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function pointerCells(pointer: InboxPointer): readonly string[] {
|
|
549
|
+
return [
|
|
550
|
+
pointer.ts,
|
|
551
|
+
pointer.agent,
|
|
552
|
+
pointer.unit,
|
|
553
|
+
pointer.status,
|
|
554
|
+
pointer.report,
|
|
555
|
+
];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function readPointers(
|
|
559
|
+
directory: string
|
|
560
|
+
): Promise<readonly InboxPointer[]> {
|
|
561
|
+
let entries: Dirent[];
|
|
562
|
+
try {
|
|
563
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (errorCode(error) === "ENOENT") {
|
|
566
|
+
throw new UserError(
|
|
567
|
+
`store is not initialized at ${dirname(directory)}; run orch init`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
const result: InboxPointer[] = [];
|
|
573
|
+
const files = entries
|
|
574
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".tsv"))
|
|
575
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
576
|
+
for (const entry of files) {
|
|
577
|
+
const raw = (await readFile(join(directory, entry.name), "utf8")).replace(
|
|
578
|
+
/\r?\n$/,
|
|
579
|
+
""
|
|
580
|
+
);
|
|
581
|
+
const row = raw.split("\t");
|
|
582
|
+
if (/[\r\n]/.test(raw) || row.length !== 5) {
|
|
583
|
+
throw new UserError(`inbox pointer ${entry.name} is malformed`);
|
|
584
|
+
}
|
|
585
|
+
result.push({
|
|
586
|
+
ts: row[0] ?? "",
|
|
587
|
+
agent: row[1] ?? "",
|
|
588
|
+
unit: row[2] ?? "",
|
|
589
|
+
status: row[3] ?? "",
|
|
590
|
+
report: row[4] ?? "",
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return result;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function renderGates(rows: readonly Gate[]): string {
|
|
597
|
+
if (rows.length === 0) {
|
|
598
|
+
return "";
|
|
599
|
+
}
|
|
600
|
+
const blocks = rows.map((gate) => {
|
|
601
|
+
const answer =
|
|
602
|
+
gate.kind === "resolved" ? `\n- Answer: ${gate.answer}` : "";
|
|
603
|
+
return `## ${gate.id}
|
|
604
|
+
|
|
605
|
+
- Status: ${gate.kind}
|
|
606
|
+
- Question: ${gate.question}
|
|
607
|
+
- Options: ${gate.options}
|
|
608
|
+
- Default: ${gate.defaultAnswer}${answer}`;
|
|
609
|
+
});
|
|
610
|
+
return `# Gates\n\n${blocks.join("\n\n")}\n`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function readGates(store: string): Promise<readonly Gate[]> {
|
|
614
|
+
const raw = (await requiredFile(join(store, "gates.md")))
|
|
615
|
+
.replace(/\r/g, "")
|
|
616
|
+
.trim();
|
|
617
|
+
if (raw.length === 0) {
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
const prefix = "# Gates\n\n## ";
|
|
621
|
+
if (!raw.startsWith(prefix)) {
|
|
622
|
+
throw new UserError("gates.md has an invalid heading");
|
|
623
|
+
}
|
|
624
|
+
const result: Gate[] = [];
|
|
625
|
+
for (const block of raw.slice(prefix.length).split("\n\n## ")) {
|
|
626
|
+
const lines = block.split("\n").filter((value) => value.length > 0);
|
|
627
|
+
const id = lines.shift() ?? "";
|
|
628
|
+
const fields = new Map<string, string>();
|
|
629
|
+
for (const value of lines) {
|
|
630
|
+
const match = /^- ([^:]+): (.*)$/.exec(value);
|
|
631
|
+
if (match === null) {
|
|
632
|
+
throw new UserError(`gates.md has a malformed gate ${id}`);
|
|
633
|
+
}
|
|
634
|
+
fields.set(match[1] ?? "", match[2] ?? "");
|
|
635
|
+
}
|
|
636
|
+
const status = fields.get("Status");
|
|
637
|
+
const question = fields.get("Question");
|
|
638
|
+
const options = fields.get("Options");
|
|
639
|
+
const defaultAnswer = fields.get("Default");
|
|
640
|
+
if (
|
|
641
|
+
id.length === 0 ||
|
|
642
|
+
question === undefined ||
|
|
643
|
+
options === undefined ||
|
|
644
|
+
defaultAnswer === undefined
|
|
645
|
+
) {
|
|
646
|
+
throw new UserError(`gates.md has a malformed gate ${id}`);
|
|
647
|
+
}
|
|
648
|
+
if (status === "open") {
|
|
649
|
+
result.push({ kind: "open", id, question, options, defaultAnswer });
|
|
650
|
+
} else if (status === "resolved" && fields.has("Answer")) {
|
|
651
|
+
result.push({
|
|
652
|
+
kind: "resolved",
|
|
653
|
+
id,
|
|
654
|
+
question,
|
|
655
|
+
options,
|
|
656
|
+
defaultAnswer,
|
|
657
|
+
answer: fields.get("Answer") ?? "",
|
|
658
|
+
});
|
|
659
|
+
} else {
|
|
660
|
+
throw new UserError(`gates.md has invalid status ${status ?? ""}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (new Set(result.map((gate) => gate.id)).size !== result.length) {
|
|
664
|
+
throw new UserError("gates.md has duplicate gate ids");
|
|
665
|
+
}
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function parseFrontier(raw: string): Frontier {
|
|
670
|
+
let value: unknown;
|
|
671
|
+
try {
|
|
672
|
+
value = JSON.parse(raw);
|
|
673
|
+
} catch {
|
|
674
|
+
throw new UserError("frontier.json is not valid JSON");
|
|
675
|
+
}
|
|
676
|
+
if (!isRecord(value)) {
|
|
677
|
+
throw new UserError("frontier.json must contain an object");
|
|
678
|
+
}
|
|
679
|
+
if (Object.keys(value).length === 0) {
|
|
680
|
+
return { generation: 0, prs: [], lowestUnmerged: null };
|
|
681
|
+
}
|
|
682
|
+
if (
|
|
683
|
+
typeof value.generation !== "number" ||
|
|
684
|
+
!Number.isSafeInteger(value.generation) ||
|
|
685
|
+
value.generation < 0 ||
|
|
686
|
+
!isUnknownArray(value.prs) ||
|
|
687
|
+
!(
|
|
688
|
+
value.lowestUnmerged === null ||
|
|
689
|
+
(typeof value.lowestUnmerged === "number" &&
|
|
690
|
+
Number.isSafeInteger(value.lowestUnmerged))
|
|
691
|
+
)
|
|
692
|
+
) {
|
|
693
|
+
throw new UserError("frontier.json has an invalid shape");
|
|
694
|
+
}
|
|
695
|
+
const prs: FrontierPr[] = [];
|
|
696
|
+
for (const row of value.prs) {
|
|
697
|
+
const state = isRecord(row)
|
|
698
|
+
? frontierPrStateOrNull(row.state)
|
|
699
|
+
: null;
|
|
700
|
+
if (
|
|
701
|
+
!isRecord(row) ||
|
|
702
|
+
typeof row.pr !== "number" ||
|
|
703
|
+
!Number.isSafeInteger(row.pr) ||
|
|
704
|
+
row.pr < 1 ||
|
|
705
|
+
typeof row.branches !== "string" ||
|
|
706
|
+
row.branches.length === 0 ||
|
|
707
|
+
typeof row.sha !== "string" ||
|
|
708
|
+
state === null
|
|
709
|
+
) {
|
|
710
|
+
throw new UserError("frontier.json has an invalid PR row");
|
|
711
|
+
}
|
|
712
|
+
prs.push({
|
|
713
|
+
pr: row.pr,
|
|
714
|
+
branches: row.branches,
|
|
715
|
+
sha: row.sha,
|
|
716
|
+
state,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
generation: value.generation,
|
|
721
|
+
prs,
|
|
722
|
+
lowestUnmerged: value.lowestUnmerged,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function readFrontier(store: string): Promise<Frontier> {
|
|
727
|
+
return parseFrontier(await requiredFile(join(store, "frontier.json")));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function readStanding(
|
|
731
|
+
store: string
|
|
732
|
+
): Promise<readonly StandingLine[]> {
|
|
733
|
+
const raw = (await requiredFile(join(store, "preferences.md"))).replace(
|
|
734
|
+
/\r/g,
|
|
735
|
+
""
|
|
736
|
+
);
|
|
737
|
+
if (raw.trim().length === 0) {
|
|
738
|
+
return [];
|
|
739
|
+
}
|
|
740
|
+
const result: StandingLine[] = [];
|
|
741
|
+
for (const value of raw.split("\n").filter((item) => item.length > 0)) {
|
|
742
|
+
const match = /^([1-9]\d*)\. (.+)$/.exec(value);
|
|
743
|
+
const number = Number(match?.[1] ?? 0);
|
|
744
|
+
if (match === null || number !== result.length + 1) {
|
|
745
|
+
throw new UserError("preferences.md has malformed numbering");
|
|
746
|
+
}
|
|
747
|
+
result.push({ number, line: match[2] ?? "" });
|
|
748
|
+
}
|
|
749
|
+
return result;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function countValues(values: readonly string[]): Counts {
|
|
753
|
+
const result: Record<string, number> = {};
|
|
754
|
+
for (const value of values) {
|
|
755
|
+
result[value] = (result[value] ?? 0) + 1;
|
|
756
|
+
}
|
|
757
|
+
return Object.fromEntries(
|
|
758
|
+
Object.entries(result).sort(([left], [right]) =>
|
|
759
|
+
left.localeCompare(right)
|
|
760
|
+
)
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function summarize(
|
|
765
|
+
unitRows: readonly Unit[],
|
|
766
|
+
ledgerRows: readonly LedgerEntry[],
|
|
767
|
+
currentFrontier: Frontier,
|
|
768
|
+
gateRows: readonly Gate[]
|
|
769
|
+
): StatusSummary {
|
|
770
|
+
return {
|
|
771
|
+
unitStates: countValues(unitRows.map((unit) => unit.state)),
|
|
772
|
+
ledgerVerdicts: countValues(ledgerRows.map((row) => row.verdict)),
|
|
773
|
+
frontierGeneration: currentFrontier.generation,
|
|
774
|
+
openGateIds: gateRows
|
|
775
|
+
.filter((gate): gate is OpenGate => gate.kind === "open")
|
|
776
|
+
.map((gate) => gate.id)
|
|
777
|
+
.sort(),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function countRecord(value: unknown): Record<string, number> | null {
|
|
782
|
+
if (!isRecord(value)) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const result: Record<string, number> = {};
|
|
786
|
+
for (const [name, count] of Object.entries(value)) {
|
|
787
|
+
if (
|
|
788
|
+
typeof count !== "number" ||
|
|
789
|
+
!Number.isSafeInteger(count) ||
|
|
790
|
+
count < 0
|
|
791
|
+
) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
result[name] = count;
|
|
795
|
+
}
|
|
796
|
+
return result;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function previousSummary(raw: string): StatusSummary | null {
|
|
800
|
+
const match = /<!-- orch-summary (.+) -->/.exec(raw);
|
|
801
|
+
if (match === null) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
let value: unknown;
|
|
805
|
+
try {
|
|
806
|
+
value = JSON.parse(match[1] ?? "");
|
|
807
|
+
} catch {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
if (
|
|
811
|
+
!isRecord(value) ||
|
|
812
|
+
typeof value.frontierGeneration !== "number" ||
|
|
813
|
+
!isUnknownArray(value.openGateIds)
|
|
814
|
+
) {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
const unitStates = countRecord(value.unitStates);
|
|
818
|
+
const ledgerVerdicts = countRecord(value.ledgerVerdicts);
|
|
819
|
+
const openGateIds = value.openGateIds.filter(
|
|
820
|
+
(item): item is string => typeof item === "string"
|
|
821
|
+
);
|
|
822
|
+
if (
|
|
823
|
+
unitStates === null ||
|
|
824
|
+
ledgerVerdicts === null ||
|
|
825
|
+
openGateIds.length !== value.openGateIds.length
|
|
826
|
+
) {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
unitStates,
|
|
831
|
+
ledgerVerdicts,
|
|
832
|
+
frontierGeneration: value.frontierGeneration,
|
|
833
|
+
openGateIds,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function changed(before: StatusSummary | null, after: StatusSummary): string {
|
|
838
|
+
if (before === null) {
|
|
839
|
+
return "first render";
|
|
840
|
+
}
|
|
841
|
+
const result: string[] = [];
|
|
842
|
+
const groups: readonly {
|
|
843
|
+
readonly label: string;
|
|
844
|
+
readonly oldCounts: Counts;
|
|
845
|
+
readonly newCounts: Counts;
|
|
846
|
+
}[] = [
|
|
847
|
+
{
|
|
848
|
+
label: "units",
|
|
849
|
+
oldCounts: before.unitStates,
|
|
850
|
+
newCounts: after.unitStates,
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
label: "ledger",
|
|
854
|
+
oldCounts: before.ledgerVerdicts,
|
|
855
|
+
newCounts: after.ledgerVerdicts,
|
|
856
|
+
},
|
|
857
|
+
];
|
|
858
|
+
for (const { label, oldCounts, newCounts } of groups) {
|
|
859
|
+
const names = [
|
|
860
|
+
...new Set([...Object.keys(oldCounts), ...Object.keys(newCounts)]),
|
|
861
|
+
].sort();
|
|
862
|
+
for (const name of names) {
|
|
863
|
+
const oldCount = oldCounts[name] ?? 0;
|
|
864
|
+
const newCount = newCounts[name] ?? 0;
|
|
865
|
+
if (oldCount !== newCount) {
|
|
866
|
+
result.push(`${label} ${name} ${oldCount}->${newCount}`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (before.frontierGeneration !== after.frontierGeneration) {
|
|
871
|
+
result.push(
|
|
872
|
+
`frontier generation ${before.frontierGeneration}->${after.frontierGeneration}`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (before.openGateIds.join("\0") !== after.openGateIds.join("\0")) {
|
|
876
|
+
result.push(
|
|
877
|
+
`open gates ${before.openGateIds.length}->${after.openGateIds.length}`
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
return result.length === 0 ? "no derived changes" : result.join("; ");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function markdown(value: string): string {
|
|
884
|
+
return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function table(
|
|
888
|
+
headers: readonly string[],
|
|
889
|
+
rows: readonly (readonly string[])[]
|
|
890
|
+
): string {
|
|
891
|
+
if (rows.length === 0) {
|
|
892
|
+
return "(none)";
|
|
893
|
+
}
|
|
894
|
+
return [
|
|
895
|
+
`| ${headers.join(" | ")} |`,
|
|
896
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
897
|
+
...rows.map((row) => `| ${row.map(markdown).join(" | ")} |`),
|
|
898
|
+
].join("\n");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function statusMarkdown(
|
|
902
|
+
unitRows: readonly Unit[],
|
|
903
|
+
ledgerRows: readonly LedgerEntry[],
|
|
904
|
+
currentFrontier: Frontier,
|
|
905
|
+
gateRows: readonly Gate[],
|
|
906
|
+
currentSummary: StatusSummary
|
|
907
|
+
): string {
|
|
908
|
+
return `# Orchestrate status
|
|
909
|
+
|
|
910
|
+
Generated: ${new Date().toISOString()}
|
|
911
|
+
|
|
912
|
+
## Units
|
|
913
|
+
|
|
914
|
+
States: ${countLine(currentSummary.unitStates)}
|
|
915
|
+
|
|
916
|
+
${table(
|
|
917
|
+
["ID", "Track", "State", "Branch", "PR", "SHA", "Brief"],
|
|
918
|
+
unitRows.map(unitCells)
|
|
919
|
+
)}
|
|
920
|
+
|
|
921
|
+
## Verification ledger
|
|
922
|
+
|
|
923
|
+
Verdicts: ${countLine(currentSummary.ledgerVerdicts)}
|
|
924
|
+
|
|
925
|
+
${table(
|
|
926
|
+
["PR", "SHA", "Verdict", "Evidence", "Verifier", "Timestamp"],
|
|
927
|
+
ledgerRows.map(ledgerCells)
|
|
928
|
+
)}
|
|
929
|
+
|
|
930
|
+
## Frontier
|
|
931
|
+
|
|
932
|
+
Generation: ${currentFrontier.generation}
|
|
933
|
+
Lowest unmerged: ${currentFrontier.lowestUnmerged ?? "none"}
|
|
934
|
+
|
|
935
|
+
${table(
|
|
936
|
+
["Branch", "PR", "SHA", "State"],
|
|
937
|
+
currentFrontier.prs.map((row) => [
|
|
938
|
+
row.branches,
|
|
939
|
+
String(row.pr),
|
|
940
|
+
row.sha,
|
|
941
|
+
row.state,
|
|
942
|
+
])
|
|
943
|
+
)}
|
|
944
|
+
|
|
945
|
+
## Gates
|
|
946
|
+
|
|
947
|
+
${table(
|
|
948
|
+
["ID", "Status", "Question", "Options", "Default", "Answer"],
|
|
949
|
+
gateRows.map((gate) => [
|
|
950
|
+
gate.id,
|
|
951
|
+
gate.kind,
|
|
952
|
+
gate.question,
|
|
953
|
+
gate.options,
|
|
954
|
+
gate.defaultAnswer,
|
|
955
|
+
gate.kind === "resolved" ? gate.answer : "",
|
|
956
|
+
])
|
|
957
|
+
)}
|
|
958
|
+
|
|
959
|
+
<!-- orch-summary ${JSON.stringify(currentSummary)} -->
|
|
960
|
+
`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function countLine(value: Counts): string {
|
|
964
|
+
const entries = Object.entries(value);
|
|
965
|
+
return entries.length === 0
|
|
966
|
+
? "none"
|
|
967
|
+
: entries.map(([name, count]) => `${name}=${count}`).join(", ");
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const OPEN_GT_PR_STATUSES = new Set([
|
|
971
|
+
"Trunk branch locked",
|
|
972
|
+
"Changes requested",
|
|
973
|
+
"Waiting on PRs in this stack to merge",
|
|
974
|
+
"Waiting on downstack merge state",
|
|
975
|
+
"Draft",
|
|
976
|
+
"Required checks failed",
|
|
977
|
+
"Undergoing failure detection",
|
|
978
|
+
"Merge queue failed on current head commit",
|
|
979
|
+
"Handed off to merge queue...",
|
|
980
|
+
"Waiting on downstack",
|
|
981
|
+
"Merge conflicts",
|
|
982
|
+
"Needs reviewers",
|
|
983
|
+
"Needs approvals",
|
|
984
|
+
"Needs restack",
|
|
985
|
+
"Queued to merge...",
|
|
986
|
+
"Ready to merge",
|
|
987
|
+
"Ready to merge as stack",
|
|
988
|
+
"Rebasing...",
|
|
989
|
+
"Waiting on CI...",
|
|
990
|
+
"Stale, needs rebase onto trunk",
|
|
991
|
+
"Unresolved comments",
|
|
992
|
+
"Waiting on required CI",
|
|
993
|
+
"Waiting to merge...",
|
|
994
|
+
]);
|
|
995
|
+
|
|
996
|
+
interface GtPullRequest {
|
|
997
|
+
readonly pr: number;
|
|
998
|
+
readonly state: FrontierPrState;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
interface GtFrontierEntry extends GtPullRequest {
|
|
1002
|
+
readonly branches: string;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function parseGtPullRequest({
|
|
1006
|
+
branch,
|
|
1007
|
+
detail,
|
|
1008
|
+
}: {
|
|
1009
|
+
branch: string;
|
|
1010
|
+
detail: string;
|
|
1011
|
+
}): GtPullRequest {
|
|
1012
|
+
const match =
|
|
1013
|
+
/^(?:\[origin\] )?PR #([1-9]\d*)(?: \(([^)\r\n]+)\))?(?: .+)?$/.exec(
|
|
1014
|
+
detail
|
|
1015
|
+
);
|
|
1016
|
+
const pr = Number(match?.[1] ?? 0);
|
|
1017
|
+
if (match === null || !Number.isSafeInteger(pr)) {
|
|
1018
|
+
throw new UserError(
|
|
1019
|
+
`gt info output has an invalid PR row for branch ${branch}: ${detail}`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
const status = match[2];
|
|
1023
|
+
if (status === "Merged") {
|
|
1024
|
+
return { pr, state: "MERGED" };
|
|
1025
|
+
}
|
|
1026
|
+
if (status === "Closed") {
|
|
1027
|
+
return { pr, state: "CLOSED" };
|
|
1028
|
+
}
|
|
1029
|
+
if (status === undefined || OPEN_GT_PR_STATUSES.has(status)) {
|
|
1030
|
+
return { pr, state: "OPEN" };
|
|
1031
|
+
}
|
|
1032
|
+
throw new UserError(
|
|
1033
|
+
`gt info output has an unknown PR state for branch ${branch}: ${status}`
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function parseGtBranches(raw: string): readonly string[] {
|
|
1038
|
+
const branches: string[] = [];
|
|
1039
|
+
const lines = raw.replace(/\r/g, "").split("\n");
|
|
1040
|
+
for (const [index, line] of lines.entries()) {
|
|
1041
|
+
if (line.length === 0) {
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
const branchMatch =
|
|
1045
|
+
/^(?:│ )*[◯◉] +([^\s]+)((?: \([^()\r\n]*\))*)$/.exec(line);
|
|
1046
|
+
if (branchMatch === null) {
|
|
1047
|
+
throw new UserError(
|
|
1048
|
+
`gt log short output has an unparseable line ${index + 1}: ${JSON.stringify(line)}`
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const branch = branchMatch[1] ?? "";
|
|
1052
|
+
if (branches.includes(branch)) {
|
|
1053
|
+
throw new UserError(
|
|
1054
|
+
`gt log short output contains duplicate branch ${branch}`
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
branches.push(branch);
|
|
1058
|
+
}
|
|
1059
|
+
const trunk = branches[0];
|
|
1060
|
+
if (trunk === undefined) {
|
|
1061
|
+
throw new UserError("gt log short output did not contain a stack");
|
|
1062
|
+
}
|
|
1063
|
+
return branches.slice(1);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function graphitePullRequest({
|
|
1067
|
+
branch,
|
|
1068
|
+
repo,
|
|
1069
|
+
}: {
|
|
1070
|
+
branch: string;
|
|
1071
|
+
repo: string;
|
|
1072
|
+
}): GtPullRequest {
|
|
1073
|
+
let raw: string;
|
|
1074
|
+
try {
|
|
1075
|
+
raw = execFileSync("gt", ["--no-interactive", "info", branch], {
|
|
1076
|
+
cwd: repo,
|
|
1077
|
+
encoding: "utf8",
|
|
1078
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
1079
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1080
|
+
});
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
throw new UserError(
|
|
1083
|
+
`gt info ${branch} failed: ${errorMessage(error)}`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
const rows = raw
|
|
1087
|
+
.replace(/\r/g, "")
|
|
1088
|
+
.split("\n")
|
|
1089
|
+
.filter(
|
|
1090
|
+
(line) =>
|
|
1091
|
+
line.startsWith("PR #") || line.startsWith("[origin] PR #")
|
|
1092
|
+
);
|
|
1093
|
+
if (rows.length === 0) {
|
|
1094
|
+
throw new UserError(
|
|
1095
|
+
`gt info output branch ${branch} has no pull request; this clone's gt metadata may predate the submit, so resolve the frontier from the stacker's clone or after gt sync`
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (rows.length > 1) {
|
|
1099
|
+
throw new UserError(
|
|
1100
|
+
`gt info output contains multiple PRs for branch ${branch}`
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
return parseGtPullRequest({ branch, detail: rows[0] ?? "" });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function graphiteFrontier(repo: string): readonly GtFrontierEntry[] {
|
|
1107
|
+
let raw: string;
|
|
1108
|
+
try {
|
|
1109
|
+
raw = execFileSync(
|
|
1110
|
+
"gt",
|
|
1111
|
+
["--no-interactive", "log", "short", "--stack", "--reverse"],
|
|
1112
|
+
{
|
|
1113
|
+
cwd: repo,
|
|
1114
|
+
encoding: "utf8",
|
|
1115
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
1116
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1117
|
+
}
|
|
1118
|
+
);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
throw new UserError(
|
|
1121
|
+
`gt log short --stack --reverse failed: ${errorMessage(error)}`
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
const result = parseGtBranches(raw).map((branch) => ({
|
|
1125
|
+
branches: branch,
|
|
1126
|
+
...graphitePullRequest({ branch, repo }),
|
|
1127
|
+
}));
|
|
1128
|
+
if (new Set(result.map((row) => row.pr)).size !== result.length) {
|
|
1129
|
+
throw new UserError("gt info output contains duplicate pull requests");
|
|
1130
|
+
}
|
|
1131
|
+
return result;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function branchSha({
|
|
1135
|
+
branch,
|
|
1136
|
+
repo,
|
|
1137
|
+
}: {
|
|
1138
|
+
branch: string;
|
|
1139
|
+
repo: string;
|
|
1140
|
+
}): string {
|
|
1141
|
+
let raw: string;
|
|
1142
|
+
try {
|
|
1143
|
+
raw = execFileSync("git", ["rev-parse", branch], {
|
|
1144
|
+
cwd: repo,
|
|
1145
|
+
encoding: "utf8",
|
|
1146
|
+
env: process.env,
|
|
1147
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1148
|
+
});
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
throw new UserError(
|
|
1151
|
+
`git rev-parse ${branch} failed: ${errorMessage(error)}`
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
const sha = raw.trim();
|
|
1155
|
+
if (!/^[0-9a-f]{40,64}$/i.test(sha)) {
|
|
1156
|
+
throw new UserError(`git rev-parse ${branch} returned an invalid SHA`);
|
|
1157
|
+
}
|
|
1158
|
+
return sha;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function resolveFrontier(repo: string): readonly FrontierPr[] {
|
|
1162
|
+
return graphiteFrontier(repo).map((row) => ({
|
|
1163
|
+
...row,
|
|
1164
|
+
sha: branchSha({ branch: row.branches, repo }),
|
|
1165
|
+
}));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function validateFrontierPin({
|
|
1169
|
+
actual,
|
|
1170
|
+
expected,
|
|
1171
|
+
}: {
|
|
1172
|
+
actual: readonly number[];
|
|
1173
|
+
expected: readonly number[];
|
|
1174
|
+
}): void {
|
|
1175
|
+
if (
|
|
1176
|
+
actual.length === expected.length &&
|
|
1177
|
+
actual.every((pr, index) => pr === expected[index])
|
|
1178
|
+
) {
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const actualSet = new Set(actual);
|
|
1182
|
+
const expectedSet = new Set(expected);
|
|
1183
|
+
const missing = expected.filter((pr) => !actualSet.has(pr));
|
|
1184
|
+
const extra = actual.filter((pr) => !expectedSet.has(pr));
|
|
1185
|
+
const drift: string[] = [];
|
|
1186
|
+
if (missing.length > 0) {
|
|
1187
|
+
drift.push(`missing from gt: ${missing.join(",")}`);
|
|
1188
|
+
}
|
|
1189
|
+
if (extra.length > 0) {
|
|
1190
|
+
drift.push(`extra in gt: ${extra.join(",")}`);
|
|
1191
|
+
}
|
|
1192
|
+
if (missing.length === 0 && extra.length === 0) {
|
|
1193
|
+
drift.push(
|
|
1194
|
+
`order differs: expected ${expected.join(",")}; gt ${actual.join(",")}`
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
throw new UserError(`frontier pin mismatch: ${drift.join("; ")}`);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
export function openStore(
|
|
1201
|
+
directory: string,
|
|
1202
|
+
options: OpenStoreOptions = {}
|
|
1203
|
+
): Store {
|
|
1204
|
+
const store = resolve(directory);
|
|
1205
|
+
let closed = false;
|
|
1206
|
+
let releaseLock: (() => Promise<void>) | null = null;
|
|
1207
|
+
let lockRequest: Promise<void> | null = null;
|
|
1208
|
+
|
|
1209
|
+
const ensureOpen = (): void => {
|
|
1210
|
+
if (closed) {
|
|
1211
|
+
throw new UserError("store is closed");
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const ensureLock = async (): Promise<void> => {
|
|
1216
|
+
ensureOpen();
|
|
1217
|
+
if (releaseLock !== null) {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (lockRequest === null) {
|
|
1221
|
+
lockRequest = acquireLock(store, options).then((release) => {
|
|
1222
|
+
releaseLock = release;
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
await lockRequest;
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
lockRequest = null;
|
|
1229
|
+
throw error;
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
const beginWrite = async (): Promise<void> => {
|
|
1234
|
+
ensureOpen();
|
|
1235
|
+
if (!(await exists(store))) {
|
|
1236
|
+
throw new UserError(
|
|
1237
|
+
`store is not initialized at ${store}; run orch init`
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
await ensureLock();
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
return {
|
|
1244
|
+
units: {
|
|
1245
|
+
add: async (params) => {
|
|
1246
|
+
await beginWrite();
|
|
1247
|
+
const row: Unit = {
|
|
1248
|
+
id: requiredCell(params.id, "unit id"),
|
|
1249
|
+
track: requiredCell(params.track, "track"),
|
|
1250
|
+
state: "pending",
|
|
1251
|
+
branch: "",
|
|
1252
|
+
pr: "",
|
|
1253
|
+
sha: "",
|
|
1254
|
+
brief:
|
|
1255
|
+
params.brief === undefined
|
|
1256
|
+
? ""
|
|
1257
|
+
: requiredCell(params.brief, "brief"),
|
|
1258
|
+
};
|
|
1259
|
+
const rows = [...(await readUnits(store))];
|
|
1260
|
+
if (rows.some((unit) => unit.id === row.id)) {
|
|
1261
|
+
throw new UserError(`unit ${row.id} already exists`);
|
|
1262
|
+
}
|
|
1263
|
+
rows.push(row);
|
|
1264
|
+
await saveUnits(store, rows);
|
|
1265
|
+
return row;
|
|
1266
|
+
},
|
|
1267
|
+
set: async (params) => {
|
|
1268
|
+
await beginWrite();
|
|
1269
|
+
const id = requiredCell(params.id, "unit id");
|
|
1270
|
+
const state = requiredCell(params.state, "state");
|
|
1271
|
+
const rows = [...(await readUnits(store))];
|
|
1272
|
+
const index = rows.findIndex((unit) => unit.id === id);
|
|
1273
|
+
const old = rows[index];
|
|
1274
|
+
if (index < 0 || old === undefined) {
|
|
1275
|
+
throw new NotFoundError(`unit ${id} not found`);
|
|
1276
|
+
}
|
|
1277
|
+
const row: Unit = {
|
|
1278
|
+
...old,
|
|
1279
|
+
state,
|
|
1280
|
+
branch:
|
|
1281
|
+
params.branch === undefined
|
|
1282
|
+
? old.branch
|
|
1283
|
+
: requiredCell(params.branch, "branch"),
|
|
1284
|
+
pr:
|
|
1285
|
+
params.pr === undefined
|
|
1286
|
+
? old.pr
|
|
1287
|
+
: String(positiveInteger(params.pr, "PR")),
|
|
1288
|
+
sha:
|
|
1289
|
+
params.sha === undefined
|
|
1290
|
+
? old.sha
|
|
1291
|
+
: requiredCell(params.sha, "SHA"),
|
|
1292
|
+
};
|
|
1293
|
+
rows[index] = row;
|
|
1294
|
+
await saveUnits(store, rows);
|
|
1295
|
+
return row;
|
|
1296
|
+
},
|
|
1297
|
+
get: async (id) => {
|
|
1298
|
+
ensureOpen();
|
|
1299
|
+
const cleanId = requiredCell(id, "unit id");
|
|
1300
|
+
const row = (await readUnits(store)).find(
|
|
1301
|
+
(unit) => unit.id === cleanId
|
|
1302
|
+
);
|
|
1303
|
+
if (row === undefined) {
|
|
1304
|
+
throw new NotFoundError(`unit ${cleanId} not found`);
|
|
1305
|
+
}
|
|
1306
|
+
return row;
|
|
1307
|
+
},
|
|
1308
|
+
list: async (params = {}) => {
|
|
1309
|
+
ensureOpen();
|
|
1310
|
+
const state =
|
|
1311
|
+
params.state === undefined
|
|
1312
|
+
? undefined
|
|
1313
|
+
: requiredCell(params.state, "state");
|
|
1314
|
+
const track =
|
|
1315
|
+
params.track === undefined
|
|
1316
|
+
? undefined
|
|
1317
|
+
: requiredCell(params.track, "track");
|
|
1318
|
+
return (await readUnits(store)).filter(
|
|
1319
|
+
(unit) =>
|
|
1320
|
+
(state === undefined || unit.state === state) &&
|
|
1321
|
+
(track === undefined || unit.track === track)
|
|
1322
|
+
);
|
|
1323
|
+
},
|
|
1324
|
+
counts: async () => {
|
|
1325
|
+
ensureOpen();
|
|
1326
|
+
return countValues(
|
|
1327
|
+
(await readUnits(store)).map((unit) => unit.state)
|
|
1328
|
+
);
|
|
1329
|
+
},
|
|
1330
|
+
},
|
|
1331
|
+
ledger: {
|
|
1332
|
+
record: async (params) => {
|
|
1333
|
+
await beginWrite();
|
|
1334
|
+
const verdict = parseVerdict(params.verdict);
|
|
1335
|
+
const row: LedgerEntry = {
|
|
1336
|
+
pr: String(positiveInteger(params.pr, "PR")),
|
|
1337
|
+
sha: requiredCell(params.sha, "SHA"),
|
|
1338
|
+
verdict,
|
|
1339
|
+
evidence: requiredCell(params.evidence, "evidence"),
|
|
1340
|
+
verifier:
|
|
1341
|
+
params.verifier === undefined
|
|
1342
|
+
? ""
|
|
1343
|
+
: requiredCell(params.verifier, "verifier"),
|
|
1344
|
+
ts: new Date().toISOString(),
|
|
1345
|
+
};
|
|
1346
|
+
const rows = [...(await readLedger(store))];
|
|
1347
|
+
const index = rows.findIndex(
|
|
1348
|
+
(old) => old.pr === row.pr && old.sha === row.sha
|
|
1349
|
+
);
|
|
1350
|
+
if (index < 0) {
|
|
1351
|
+
rows.push(row);
|
|
1352
|
+
} else {
|
|
1353
|
+
rows[index] = row;
|
|
1354
|
+
}
|
|
1355
|
+
await saveLedger(store, rows);
|
|
1356
|
+
return row;
|
|
1357
|
+
},
|
|
1358
|
+
check: async (params) => {
|
|
1359
|
+
ensureOpen();
|
|
1360
|
+
const pr = String(positiveInteger(params.pr, "PR"));
|
|
1361
|
+
const sha = requiredCell(params.sha, "SHA");
|
|
1362
|
+
const row = (await readLedger(store)).find(
|
|
1363
|
+
(value) => value.pr === pr && value.sha === sha
|
|
1364
|
+
);
|
|
1365
|
+
if (row === undefined) {
|
|
1366
|
+
throw new NotFoundError("NOT-VERIFIED", {
|
|
1367
|
+
compact: "NOT-VERIFIED",
|
|
1368
|
+
json: { pr, sha, verdict: "NOT-VERIFIED" },
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
return row;
|
|
1372
|
+
},
|
|
1373
|
+
summary: async () => {
|
|
1374
|
+
ensureOpen();
|
|
1375
|
+
return countValues(
|
|
1376
|
+
(await readLedger(store)).map((row) => row.verdict)
|
|
1377
|
+
);
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
inbox: {
|
|
1381
|
+
push: async (params) => {
|
|
1382
|
+
await beginWrite();
|
|
1383
|
+
const pointer: InboxPointer = {
|
|
1384
|
+
ts: new Date().toISOString(),
|
|
1385
|
+
agent: requiredCell(params.agent, "agent"),
|
|
1386
|
+
unit: requiredCell(params.unit, "unit"),
|
|
1387
|
+
status: requiredCell(params.status, "status"),
|
|
1388
|
+
report:
|
|
1389
|
+
params.report === undefined
|
|
1390
|
+
? ""
|
|
1391
|
+
: requiredCell(params.report, "report"),
|
|
1392
|
+
};
|
|
1393
|
+
const inbox = join(store, "inbox");
|
|
1394
|
+
if (!(await exists(inbox))) {
|
|
1395
|
+
throw new UserError(
|
|
1396
|
+
`store is not initialized at ${store}; run orch init`
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
const timestamp = pointer.ts.replace(/[:.]/g, "-");
|
|
1400
|
+
const filename = `${timestamp}-${process.pid}-${randomUUID()}.tsv`;
|
|
1401
|
+
const contents = `${pointerCells(pointer).map(cleanCell).join("\t")}\n`;
|
|
1402
|
+
await atomicWrite(join(inbox, filename), contents);
|
|
1403
|
+
return { pointer, filename };
|
|
1404
|
+
},
|
|
1405
|
+
drain: async () => {
|
|
1406
|
+
await beginWrite();
|
|
1407
|
+
const inbox = join(store, "inbox");
|
|
1408
|
+
const rows = await readPointers(inbox);
|
|
1409
|
+
const drained = join(
|
|
1410
|
+
store,
|
|
1411
|
+
`.inbox-drain-${process.pid}-${randomUUID()}`
|
|
1412
|
+
);
|
|
1413
|
+
await rename(inbox, drained);
|
|
1414
|
+
try {
|
|
1415
|
+
await mkdir(inbox);
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
await rename(drained, inbox);
|
|
1418
|
+
throw error;
|
|
1419
|
+
}
|
|
1420
|
+
await rm(drained, { recursive: true, force: true });
|
|
1421
|
+
return rows;
|
|
1422
|
+
},
|
|
1423
|
+
peek: async () => {
|
|
1424
|
+
ensureOpen();
|
|
1425
|
+
return readPointers(join(store, "inbox"));
|
|
1426
|
+
},
|
|
1427
|
+
count: async () => {
|
|
1428
|
+
ensureOpen();
|
|
1429
|
+
return (await readPointers(join(store, "inbox"))).length;
|
|
1430
|
+
},
|
|
1431
|
+
},
|
|
1432
|
+
gates: {
|
|
1433
|
+
park: async (params) => {
|
|
1434
|
+
await beginWrite();
|
|
1435
|
+
const gate: OpenGate = {
|
|
1436
|
+
kind: "open",
|
|
1437
|
+
id: requiredLine(params.id, "gate id"),
|
|
1438
|
+
question: requiredLine(params.question, "question"),
|
|
1439
|
+
options: requiredLine(params.options, "options"),
|
|
1440
|
+
defaultAnswer: requiredLine(
|
|
1441
|
+
params.defaultAnswer,
|
|
1442
|
+
"default"
|
|
1443
|
+
),
|
|
1444
|
+
};
|
|
1445
|
+
const rows = [...(await readGates(store))];
|
|
1446
|
+
const index = rows.findIndex((old) => old.id === gate.id);
|
|
1447
|
+
if (index < 0) {
|
|
1448
|
+
rows.push(gate);
|
|
1449
|
+
} else {
|
|
1450
|
+
rows[index] = gate;
|
|
1451
|
+
}
|
|
1452
|
+
await atomicWrite(join(store, "gates.md"), renderGates(rows));
|
|
1453
|
+
return gate;
|
|
1454
|
+
},
|
|
1455
|
+
list: async () => {
|
|
1456
|
+
ensureOpen();
|
|
1457
|
+
return (await readGates(store)).filter(
|
|
1458
|
+
(gate): gate is OpenGate => gate.kind === "open"
|
|
1459
|
+
);
|
|
1460
|
+
},
|
|
1461
|
+
resolve: async (params) => {
|
|
1462
|
+
await beginWrite();
|
|
1463
|
+
const id = requiredLine(params.id, "gate id");
|
|
1464
|
+
const rows = [...(await readGates(store))];
|
|
1465
|
+
const index = rows.findIndex((gate) => gate.id === id);
|
|
1466
|
+
const old = rows[index];
|
|
1467
|
+
if (index < 0 || old === undefined) {
|
|
1468
|
+
throw new NotFoundError(`gate ${id} not found`);
|
|
1469
|
+
}
|
|
1470
|
+
const gate: ResolvedGate = {
|
|
1471
|
+
kind: "resolved",
|
|
1472
|
+
id: old.id,
|
|
1473
|
+
question: old.question,
|
|
1474
|
+
options: old.options,
|
|
1475
|
+
defaultAnswer: old.defaultAnswer,
|
|
1476
|
+
answer: requiredLine(params.answer, "answer"),
|
|
1477
|
+
};
|
|
1478
|
+
rows[index] = gate;
|
|
1479
|
+
await atomicWrite(join(store, "gates.md"), renderGates(rows));
|
|
1480
|
+
return gate;
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
frontier: {
|
|
1484
|
+
set: async (params) => {
|
|
1485
|
+
await beginWrite();
|
|
1486
|
+
const repo = resolve(requiredLine(params.repo, "repo directory"));
|
|
1487
|
+
const pin =
|
|
1488
|
+
params.prs === undefined
|
|
1489
|
+
? undefined
|
|
1490
|
+
: params.prs.map((pr) => positiveInteger(pr, "PR"));
|
|
1491
|
+
if (pin !== undefined && new Set(pin).size !== pin.length) {
|
|
1492
|
+
throw new UserError("--prs must not contain duplicates");
|
|
1493
|
+
}
|
|
1494
|
+
const old = await readFrontier(store);
|
|
1495
|
+
const prs = resolveFrontier(repo);
|
|
1496
|
+
if (pin !== undefined) {
|
|
1497
|
+
validateFrontierPin({
|
|
1498
|
+
actual: prs.map((row) => row.pr),
|
|
1499
|
+
expected: pin,
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
const value: Frontier = {
|
|
1503
|
+
generation: old.generation + 1,
|
|
1504
|
+
prs,
|
|
1505
|
+
lowestUnmerged: prs.find((row) => row.state === "OPEN")?.pr ?? null,
|
|
1506
|
+
};
|
|
1507
|
+
await atomicWrite(
|
|
1508
|
+
join(store, "frontier.json"),
|
|
1509
|
+
`${JSON.stringify(value, null, 2)}\n`
|
|
1510
|
+
);
|
|
1511
|
+
return value;
|
|
1512
|
+
},
|
|
1513
|
+
show: async () => {
|
|
1514
|
+
ensureOpen();
|
|
1515
|
+
return readFrontier(store);
|
|
1516
|
+
},
|
|
1517
|
+
},
|
|
1518
|
+
standing: {
|
|
1519
|
+
show: async () => {
|
|
1520
|
+
ensureOpen();
|
|
1521
|
+
return readStanding(store);
|
|
1522
|
+
},
|
|
1523
|
+
add: async (params) => {
|
|
1524
|
+
await beginWrite();
|
|
1525
|
+
const rows = [...(await readStanding(store))];
|
|
1526
|
+
const item: StandingLine = {
|
|
1527
|
+
number: rows.length + 1,
|
|
1528
|
+
line: requiredLine(params.line, "standing order"),
|
|
1529
|
+
};
|
|
1530
|
+
rows.push(item);
|
|
1531
|
+
await atomicWrite(
|
|
1532
|
+
join(store, "preferences.md"),
|
|
1533
|
+
`${rows.map((row) => `${row.number}. ${row.line}`).join("\n")}\n`
|
|
1534
|
+
);
|
|
1535
|
+
return item;
|
|
1536
|
+
},
|
|
1537
|
+
},
|
|
1538
|
+
status: {
|
|
1539
|
+
render: async () => {
|
|
1540
|
+
await beginWrite();
|
|
1541
|
+
const unitRows = await readUnits(store);
|
|
1542
|
+
const ledgerRows = await readLedger(store);
|
|
1543
|
+
const currentFrontier = await readFrontier(store);
|
|
1544
|
+
const gateRows = await readGates(store);
|
|
1545
|
+
const currentSummary = summarize(
|
|
1546
|
+
unitRows,
|
|
1547
|
+
ledgerRows,
|
|
1548
|
+
currentFrontier,
|
|
1549
|
+
gateRows
|
|
1550
|
+
);
|
|
1551
|
+
const path = join(store, "status.md");
|
|
1552
|
+
const before = (await exists(path))
|
|
1553
|
+
? previousSummary(await readFile(path, "utf8"))
|
|
1554
|
+
: null;
|
|
1555
|
+
const change = changed(before, currentSummary);
|
|
1556
|
+
await atomicWrite(
|
|
1557
|
+
path,
|
|
1558
|
+
statusMarkdown(
|
|
1559
|
+
unitRows,
|
|
1560
|
+
ledgerRows,
|
|
1561
|
+
currentFrontier,
|
|
1562
|
+
gateRows,
|
|
1563
|
+
currentSummary
|
|
1564
|
+
)
|
|
1565
|
+
);
|
|
1566
|
+
return {
|
|
1567
|
+
units: unitRows,
|
|
1568
|
+
ledger: ledgerRows,
|
|
1569
|
+
frontier: currentFrontier,
|
|
1570
|
+
gates: gateRows,
|
|
1571
|
+
summary: currentSummary,
|
|
1572
|
+
changed: change,
|
|
1573
|
+
};
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
init: async () => {
|
|
1577
|
+
ensureOpen();
|
|
1578
|
+
await mkdir(store, { recursive: true });
|
|
1579
|
+
await ensureLock();
|
|
1580
|
+
await writeIfMissing(join(store, "units.tsv"), `${UNIT_HEADER}\n`);
|
|
1581
|
+
await writeIfMissing(join(store, "ledger.tsv"), `${LEDGER_HEADER}\n`);
|
|
1582
|
+
await mkdir(join(store, "inbox"), { recursive: true });
|
|
1583
|
+
await writeIfMissing(join(store, "gates.md"), "");
|
|
1584
|
+
await writeIfMissing(join(store, "preferences.md"), "");
|
|
1585
|
+
await writeIfMissing(join(store, "frontier.json"), "{}\n");
|
|
1586
|
+
return { store };
|
|
1587
|
+
},
|
|
1588
|
+
close: async () => {
|
|
1589
|
+
if (closed) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (lockRequest !== null) {
|
|
1593
|
+
try {
|
|
1594
|
+
await lockRequest;
|
|
1595
|
+
} catch {
|
|
1596
|
+
// A failed acquisition has no lock to release.
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
const release = releaseLock;
|
|
1600
|
+
releaseLock = null;
|
|
1601
|
+
closed = true;
|
|
1602
|
+
if (release !== null) {
|
|
1603
|
+
await release();
|
|
1604
|
+
}
|
|
1605
|
+
},
|
|
1606
|
+
};
|
|
1607
|
+
}
|