@mgiles/perk 3.1.0 → 3.3.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/README.md +5 -0
- package/extension/authoring/gist/draft.ts +198 -0
- package/extension/authoring/gist/prose.ts +46 -0
- package/extension/authoring/gist/review.ts +133 -0
- package/extension/authoring/gist/save.ts +118 -0
- package/extension/authoring/objective/draft.ts +345 -0
- package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
- package/extension/authoring/objective/planning.ts +124 -0
- package/extension/authoring/objective/prose.ts +103 -0
- package/extension/authoring/objective/review.ts +128 -0
- package/extension/authoring/objective/save.ts +224 -0
- package/extension/authoring/plan/draft.ts +84 -0
- package/extension/authoring/plan/prose.ts +41 -0
- package/extension/authoring/plan/review.ts +269 -0
- package/extension/authoring/plan/save.ts +256 -0
- package/extension/authoring/plan/source.ts +82 -0
- package/extension/authoring/refinement/context.ts +468 -0
- package/extension/authoring/refinement/draft.ts +261 -0
- package/extension/authoring/refinement/prose.ts +79 -0
- package/extension/authoring/refinement/review.ts +111 -0
- package/extension/authoring/refinement/save.ts +119 -0
- package/extension/authoring/review/approvalGate.ts +34 -0
- package/extension/authoring/review/draftContext.ts +68 -0
- package/extension/codeReview/automated.ts +352 -0
- package/extension/codeReview/submission.ts +229 -0
- package/extension/delivery/address.ts +295 -0
- package/extension/delivery/ci.ts +355 -0
- package/extension/delivery/commitCompact.ts +93 -0
- package/extension/delivery/conflictResolution.ts +247 -0
- package/extension/delivery/ready.ts +193 -0
- package/extension/delivery/stackConflict.ts +361 -0
- package/extension/delivery/stackObjective.ts +16 -0
- package/extension/delivery/stackReconcile.ts +165 -0
- package/extension/delivery/submit.ts +171 -0
- package/extension/index.ts +369 -359
- package/extension/learning/analystWave.ts +324 -0
- package/extension/learning/audit.ts +667 -0
- package/extension/learning/capture.ts +92 -0
- package/extension/learning/containment.ts +104 -0
- package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
- package/extension/learning/dreamAnalysis.ts +435 -0
- package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
- package/extension/{waves → learning}/dreamReport.ts +35 -31
- package/extension/learning/harvest.ts +491 -0
- package/extension/learning/prose.ts +66 -0
- package/extension/learning/routing.ts +79 -0
- package/extension/pi/v1/bashScanTimeout.ts +64 -0
- package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
- package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
- package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
- package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
- package/extension/pi/v1/codeReview/stack.ts +606 -0
- package/extension/pi/v1/codeReview/submit.ts +354 -0
- package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
- package/extension/pi/v1/contextEvidence.ts +80 -0
- package/extension/pi/v1/contextInjection.ts +207 -0
- package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
- package/extension/pi/v1/delivery/ci.ts +570 -0
- package/extension/pi/v1/delivery/commitCompact.ts +201 -0
- package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
- package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
- package/extension/pi/v1/delivery/ready.ts +322 -0
- package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
- package/extension/pi/v1/delivery/stackDrive.ts +120 -0
- package/extension/pi/v1/delivery/stackLand.ts +223 -0
- package/extension/pi/v1/delivery/stackRecover.ts +265 -0
- package/extension/pi/v1/delivery/stackStatus.ts +237 -0
- package/extension/pi/v1/delivery/stackSync.ts +658 -0
- package/extension/pi/v1/delivery/submit.ts +389 -0
- package/extension/pi/v1/delivery/submitConflict.ts +186 -0
- package/extension/pi/v1/draftReview.ts +431 -0
- package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
- package/extension/pi/v1/gist.ts +794 -0
- package/extension/pi/v1/learning/audit.ts +186 -0
- package/extension/pi/v1/learning/dream.ts +207 -0
- package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
- package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
- package/extension/pi/v1/learning/learn.ts +585 -0
- package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
- package/extension/{factories → pi/v1}/objective.ts +53 -33
- package/extension/pi/v1/objectiveAuthoring.ts +672 -0
- package/extension/pi/v1/objectiveDreamGate.ts +160 -0
- package/extension/pi/v1/objectivePlanning.ts +776 -0
- package/extension/pi/v1/objectiveRefinement.ts +1320 -0
- package/extension/pi/v1/objectiveReview.ts +451 -0
- package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
- package/extension/pi/v1/plan.ts +812 -0
- package/extension/pi/v1/planReview.ts +820 -0
- package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
- package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
- package/extension/pi/v1/providers/plannotator.ts +487 -0
- package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
- package/extension/pi/v1/providers/selection.ts +43 -0
- package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
- package/extension/pi/v1/review.ts +538 -0
- package/extension/pi/v1/reviewOutcome.ts +9 -0
- package/extension/pi/v1/scoutWave.ts +318 -0
- package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
- package/extension/session/branchWorkflowSession.ts +60 -0
- package/extension/session/lifecycle.ts +644 -0
- package/extension/session/lifecycleGates.ts +64 -0
- package/extension/session/saveDestination.ts +87 -0
- package/extension/session/workflowSession.ts +971 -0
- package/extension/substrate/agentScratch.ts +27 -54
- package/extension/substrate/bashScanTimeout.ts +181 -0
- package/extension/substrate/bindingDelivery.ts +38 -30
- package/extension/substrate/bindings.ts +4 -5
- package/extension/substrate/cache.ts +64 -12
- package/extension/substrate/childRestrictions.ts +39 -0
- package/extension/substrate/coldDoor.ts +17 -1
- package/extension/substrate/config.ts +157 -19
- package/extension/substrate/git.ts +88 -6
- package/extension/substrate/modelVisible.ts +53 -0
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/prompts.ts +22 -0
- package/extension/substrate/registry.ts +2 -0
- package/extension/substrate/resolverLease.ts +364 -0
- package/extension/substrate/sessionData.ts +85 -152
- package/extension/substrate/toolGating.ts +279 -84
- package/extension/substrate/unifiedDiff.ts +1 -1
- package/extension/substrate/workflowState.ts +191 -166
- package/extension/substrate/worktreeResolverLock.ts +261 -0
- package/extension/surfaces/surfaces.ts +79 -27
- package/extension/waves/adversarialReviewWave.ts +103 -48
- package/extension/waves/blockedReports.ts +59 -0
- package/extension/waves/draftReviewWave.ts +42 -42
- package/extension/waves/laneIdentity.ts +77 -0
- package/extension/waves/objectiveExplorerWave.ts +24 -24
- package/extension/waves/prReviewWave.ts +89 -77
- package/extension/waves/reportWave.ts +438 -578
- package/extension/waves/reviewClassifierWave.ts +22 -22
- package/extension/waves/rpcAdapter.ts +100 -15
- package/extension/waves/scoutWave.ts +192 -0
- package/extension/waves/transport.ts +480 -0
- package/extension/worker/sdkAdapter.ts +494 -0
- package/extension/worker/stageExecution.ts +679 -0
- package/extension/workerMain.ts +18 -19
- package/package.json +6 -4
- package/prompts/_fixtures/live.yaml +98 -10
- package/prompts/contexts/adapters/plannotator-gist.md +6 -0
- package/prompts/contexts/adapters/plannotator-objective.md +6 -0
- package/prompts/contexts/adapters/plannotator-plan.md +8 -1
- package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
- package/prompts/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/objective-refinement.md +17 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/contexts/read-only.md +1 -1
- package/prompts/stages/conflict-resolution-continuation.md +9 -0
- package/prompts/stages/conflict-resolution.md +4 -4
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +2 -2
- package/prompts/stages/objective-plan/seed.md +9 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-refine/seed.md +18 -0
- package/prompts/stages/objective-review-browser.md +4 -4
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/plan-review-browser.md +4 -4
- package/prompts/stages/pr-review-browser/active.md +3 -4
- package/prompts/stages/pr-review-browser/foreign.md +3 -4
- package/prompts/stages/pr-review-terminal/active.md +3 -3
- package/prompts/stages/pr-review-terminal/foreign.md +3 -3
- package/prompts/stages/pr-review.md +3 -3
- package/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +22 -0
- package/shared/README.md +8 -3
- package/shared/bindings.yaml +6 -3
- package/shared/contracts.md +4499 -2147
- package/shared/fixtures/issues-table.json +130 -0
- package/shared/registry.yaml +29 -1
- package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
- package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
- package/extension/adapters/planAdapterPlannotator.ts +0 -362
- package/extension/doors/auditWaveTools.ts +0 -352
- package/extension/doors/ciExecutor.ts +0 -756
- package/extension/doors/commitCompact.ts +0 -251
- package/extension/doors/dreamWaveTools.ts +0 -475
- package/extension/doors/learn.ts +0 -655
- package/extension/doors/objectiveStack.ts +0 -1143
- package/extension/doors/prReviewDynamic.ts +0 -276
- package/extension/doors/ready.ts +0 -87
- package/extension/doors/submit.ts +0 -347
- package/extension/doors/submitPrReview.ts +0 -408
- package/extension/factories/gistAuthor.ts +0 -94
- package/extension/factories/gistDraft.ts +0 -265
- package/extension/factories/gistSave.ts +0 -251
- package/extension/factories/implementHere.ts +0 -116
- package/extension/factories/objectiveAuthor.ts +0 -98
- package/extension/factories/objectiveDraft.ts +0 -466
- package/extension/factories/objectivePlan.ts +0 -975
- package/extension/factories/objectiveSave.ts +0 -363
- package/extension/factories/planDraft.ts +0 -140
- package/extension/factories/planMode.ts +0 -205
- package/extension/factories/planReview.ts +0 -1237
- package/extension/factories/planSave.ts +0 -604
- package/extension/factories/planTitle.ts +0 -141
- package/extension/substrate/structuredOutput.ts +0 -202
- package/extension/waves/auditWave.ts +0 -312
- package/extension/waves/harvestWave.ts +0 -399
- package/extension/waves/learnWave.ts +0 -155
- package/extension/waves/memoryAdapter.ts +0 -139
- package/extension/waves/prReviewDynamicWave.ts +0 -777
- package/extension/worker/readOnlySession.ts +0 -294
- package/extension/worker/worker.ts +0 -899
- package/prompts/stages/pr-review-dynamic.md +0 -7
- package/shared/contracts-history.md +0 -605
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// The conflict-resolver claim (contracts.md §8.51): a machine-local SESSION CLAIM on a retained
|
|
2
|
+
// sync-continuation operation, taken during preparation before native execution. It is NOT a
|
|
3
|
+
// child-lifecycle-bound execution lock: after a verified dispatch preparation this claim stays
|
|
4
|
+
// held across every resolver outcome, including confirmed completion. Only preparation failure
|
|
5
|
+
// releases this call's token. The separate worktree execution lock cannot be reclaimed here.
|
|
6
|
+
// The session claim self-heals instead, via the
|
|
7
|
+
// reclaimability predicate: the holder pid is dead, the recorded operation was consumed (a fresh
|
|
8
|
+
// conflict minted a new operation id), or the lease is missing/corrupt and the lock dir has aged
|
|
9
|
+
// past `RECLAIM_GRACE_MS`. The accepted residual: a live session's claim on a still-pending SAME
|
|
10
|
+
// operation blocks other sessions' dispatch until that session exits or the operation is
|
|
11
|
+
// consumed — the busy reason names the holder pid, the lock path, and the remediation.
|
|
12
|
+
//
|
|
13
|
+
// Reclaim mechanics mirror `hunkFeedback/store.ts::acquireLease` (the interleaving-safe recipe):
|
|
14
|
+
// judge reclaimability → quarantine-RENAME the observed lock dir to a unique name (rename is
|
|
15
|
+
// atomic, so two reclaimers can never both delete a successor) → post-rename re-judgment on the
|
|
16
|
+
// MOVED state (a claim that changed since the judgment, or whose lease is missing/corrupt but
|
|
17
|
+
// still inside the grace window, is renamed back — a raced-in claim is NEVER stolen, whatever
|
|
18
|
+
// operation it names) → ONE fresh-acquire retry → best-effort quarantine removal; a lost retry
|
|
19
|
+
// is an honest busy. Deletion only ever targets our own quarantine dir or our own same-call
|
|
20
|
+
// acquisition, and the explicit withheld-dispatch release is token-fenced through its own
|
|
21
|
+
// quarantine-verify (`releaseResolverClaim`).
|
|
22
|
+
//
|
|
23
|
+
// Error posture: a MISSING or MALFORMED lease is DATA (it routes to the reclaim rules), and the
|
|
24
|
+
// expected race disappearances (ENOENT on read/stat/rename, EEXIST on mkdir) are contention —
|
|
25
|
+
// every OTHER filesystem failure propagates to the typed `io_error` arm, never a fabricated
|
|
26
|
+
// busy/reclaim judgment.
|
|
27
|
+
|
|
28
|
+
import { randomBytes } from "node:crypto";
|
|
29
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { atomicWriteFileSync } from "./cache.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A corrupt/missing `lease.json` is reclaimable only once the lock dir is older than this —
|
|
35
|
+
* closes the winner's mkdir↔first-write window (implementation constant, not config).
|
|
36
|
+
*/
|
|
37
|
+
export const RECLAIM_GRACE_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
/** The claim lock dir sits beside the continuation manifest it guards. */
|
|
40
|
+
export function resolverLockDir(manifestPath: string): string {
|
|
41
|
+
return `${manifestPath}.resolver-lock`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type LeaseAcquisition =
|
|
45
|
+
| { acquired: true; token: string }
|
|
46
|
+
| { acquired: false; kind: "busy" | "io_error"; reason: string };
|
|
47
|
+
|
|
48
|
+
/** Deterministic-interleave seams for the reclaim-race tests — never set in production. */
|
|
49
|
+
export interface AcquireRaceHooks {
|
|
50
|
+
/** Runs after the reclaimability judgment, before the quarantine rename. */
|
|
51
|
+
beforeQuarantine?(): void;
|
|
52
|
+
/** Runs after the quarantine rename attempt, before the fresh-acquire retry. */
|
|
53
|
+
afterQuarantine?(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The raw fs operations the claim touches — injectable ONLY for deterministic fault tests. */
|
|
57
|
+
export interface LeaseFsOps {
|
|
58
|
+
/** Non-recursive mkdir: EEXIST is the contention signal. */
|
|
59
|
+
mkdir(path: string): void;
|
|
60
|
+
/** utf8 read. */
|
|
61
|
+
readFile(path: string): string;
|
|
62
|
+
/** Atomic lease write (temp + rename — the atomicWriteFileSync discipline). */
|
|
63
|
+
writeLease(path: string, content: string): void;
|
|
64
|
+
rename(from: string, to: string): void;
|
|
65
|
+
/** Recursive, force. */
|
|
66
|
+
rm(path: string): void;
|
|
67
|
+
statMtimeMs(path: string): number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const REAL_FS: LeaseFsOps = {
|
|
71
|
+
mkdir: (path) => mkdirSync(path),
|
|
72
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
73
|
+
writeLease: (path, content) => atomicWriteFileSync(path, content),
|
|
74
|
+
rename: (from, to) => renameSync(from, to),
|
|
75
|
+
rm: (path) => rmSync(path, { recursive: true, force: true }),
|
|
76
|
+
statMtimeMs: (path) => statSync(path).mtimeMs,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface ResolverLease {
|
|
80
|
+
schema: 1;
|
|
81
|
+
pid: number;
|
|
82
|
+
operation_id: string;
|
|
83
|
+
/** The per-acquisition ownership fence: rotated on every (re)acquire; release verifies it. */
|
|
84
|
+
token: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
88
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function errorCode(error: unknown): string | undefined {
|
|
92
|
+
return (error as NodeJS.ErrnoException).code;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read the lease as DATA: a missing file or malformed/mis-shaped content is `null` (the
|
|
97
|
+
* corrupt/missing reclaim rules own it). Any OTHER read failure (EACCES, EIO, EISDIR, …) is a
|
|
98
|
+
* genuine I/O failure and THROWS so the caller's typed `io_error` arm reports it honestly.
|
|
99
|
+
*/
|
|
100
|
+
function readLease(fs: LeaseFsOps, lockDir: string): ResolverLease | null {
|
|
101
|
+
let raw: string;
|
|
102
|
+
try {
|
|
103
|
+
raw = fs.readFile(join(lockDir, "lease.json"));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (errorCode(error) === "ENOENT") return null;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
let parsed: unknown;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(raw);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
isRecord(parsed) &&
|
|
116
|
+
parsed.schema === 1 &&
|
|
117
|
+
typeof parsed.pid === "number" &&
|
|
118
|
+
Number.isInteger(parsed.pid) &&
|
|
119
|
+
typeof parsed.operation_id === "string" &&
|
|
120
|
+
typeof parsed.token === "string"
|
|
121
|
+
) {
|
|
122
|
+
return parsed as unknown as ResolverLease;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The lock dir's mtime, or -Infinity when it vanished (ENOENT — a racing reclaim finished);
|
|
128
|
+
* any other stat failure throws to the typed `io_error` arm. */
|
|
129
|
+
function lockDirBasisMs(fs: LeaseFsOps, path: string): number {
|
|
130
|
+
try {
|
|
131
|
+
return fs.statMtimeMs(path);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (errorCode(error) === "ENOENT") return Number.NEGATIVE_INFINITY;
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function leaseBytes(lease: ResolverLease): string {
|
|
139
|
+
return `${JSON.stringify(lease)}\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function mintToken(): string {
|
|
143
|
+
return randomBytes(8).toString("hex");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Liveness probe: ESRCH = dead; EPERM (a foreign-uid process) and success both count alive. */
|
|
147
|
+
function defaultIsAlive(pid: number): boolean {
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, 0);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return errorCode(error) !== "ESRCH";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Atomic non-recursive `mkdir` (EEXIST = contention) + the first lease write. Returns the fresh
|
|
158
|
+
* token on acquisition, null on contention; throws on any other fs failure — after best-effort
|
|
159
|
+
* removing the dir THIS call created (we own it; any surviving residue self-heals via the
|
|
160
|
+
* aged-corrupt reclaim rule).
|
|
161
|
+
*/
|
|
162
|
+
function tryFreshAcquire(
|
|
163
|
+
fs: LeaseFsOps,
|
|
164
|
+
lockDir: string,
|
|
165
|
+
pid: number,
|
|
166
|
+
operationId: string,
|
|
167
|
+
): string | null {
|
|
168
|
+
try {
|
|
169
|
+
fs.mkdir(lockDir);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (errorCode(error) === "EEXIST") return null;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
const token = mintToken();
|
|
175
|
+
try {
|
|
176
|
+
fs.writeLease(
|
|
177
|
+
join(lockDir, "lease.json"),
|
|
178
|
+
leaseBytes({ schema: 1, pid, operation_id: operationId, token }),
|
|
179
|
+
);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
try {
|
|
182
|
+
fs.rm(lockDir);
|
|
183
|
+
} catch {
|
|
184
|
+
// best-effort — the corrupt-lease reclaim rule collects it once it ages
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
return token;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function busyHolder(pid: number, lockDir: string): string {
|
|
192
|
+
return (
|
|
193
|
+
`another live session (pid ${pid}) holds the resolver claim at ${lockDir} — ` +
|
|
194
|
+
"dispatch from that session, or remove the lock dir if it is provably stale"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function busyUnidentified(lockDir: string): string {
|
|
199
|
+
return (
|
|
200
|
+
`an unidentified holder claims the resolver lock at ${lockDir} (lease unreadable, ` +
|
|
201
|
+
"created recently) — retry shortly, or remove the lock dir if it is provably stale"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sameLease(a: ResolverLease, b: ResolverLease | null): boolean {
|
|
206
|
+
return b !== null && a.pid === b.pid && a.operation_id === b.operation_id && a.token === b.token;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Acquire the resolver claim for `operationId` on the continuation at `manifestPath`. Never
|
|
211
|
+
* throws: every genuine filesystem failure is caught and returned as `kind: "io_error"` (the
|
|
212
|
+
* expected race disappearances are classified inline — see the module doc). Same-pid contention
|
|
213
|
+
* is an idempotent REACQUIRE that rewrites `lease.json` with the CURRENT operation id and a
|
|
214
|
+
* fresh token (a continue-time NEW conflict reuses the same operation id — the original
|
|
215
|
+
* dispatching session re-claims; it never routes through reclaim). On success the returned
|
|
216
|
+
* `token` is the ownership fence a withheld dispatch passes to `releaseResolverClaim`.
|
|
217
|
+
* `pid`/`isAlive`/`now`/`hooks`/`fs` are injectable for deterministic tests.
|
|
218
|
+
*/
|
|
219
|
+
export function acquireResolverLease(
|
|
220
|
+
manifestPath: string,
|
|
221
|
+
operationId: string,
|
|
222
|
+
opts?: {
|
|
223
|
+
pid?: number;
|
|
224
|
+
isAlive?: (pid: number) => boolean;
|
|
225
|
+
now?: () => number;
|
|
226
|
+
hooks?: AcquireRaceHooks;
|
|
227
|
+
fs?: Partial<LeaseFsOps>;
|
|
228
|
+
},
|
|
229
|
+
): LeaseAcquisition {
|
|
230
|
+
const pid = opts?.pid ?? process.pid;
|
|
231
|
+
const isAlive = opts?.isAlive ?? defaultIsAlive;
|
|
232
|
+
const now = opts?.now ?? Date.now;
|
|
233
|
+
const hooks = opts?.hooks ?? {};
|
|
234
|
+
const fs: LeaseFsOps = { ...REAL_FS, ...(opts?.fs ?? {}) };
|
|
235
|
+
const lockDir = resolverLockDir(manifestPath);
|
|
236
|
+
try {
|
|
237
|
+
const fresh = tryFreshAcquire(fs, lockDir, pid, operationId);
|
|
238
|
+
if (fresh !== null) return { acquired: true, token: fresh };
|
|
239
|
+
|
|
240
|
+
const observed = readLease(fs, lockDir);
|
|
241
|
+
if (observed !== null && observed.pid === pid) {
|
|
242
|
+
// Same pid: reacquire, not reclaim — rewrite with the current operation id + fresh token.
|
|
243
|
+
const token = mintToken();
|
|
244
|
+
fs.writeLease(
|
|
245
|
+
join(lockDir, "lease.json"),
|
|
246
|
+
leaseBytes({ schema: 1, pid, operation_id: operationId, token }),
|
|
247
|
+
);
|
|
248
|
+
return { acquired: true, token };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Reclaimability: dead holder / consumed operation / aged corrupt-or-missing lease.
|
|
252
|
+
if (observed !== null) {
|
|
253
|
+
if (isAlive(observed.pid) && observed.operation_id === operationId) {
|
|
254
|
+
return { acquired: false, kind: "busy", reason: busyHolder(observed.pid, lockDir) };
|
|
255
|
+
}
|
|
256
|
+
} else if (now() - lockDirBasisMs(fs, lockDir) < RECLAIM_GRACE_MS) {
|
|
257
|
+
// Corrupt/missing lease.json inside the grace window (a winner may sit between its
|
|
258
|
+
// mkdir and first write) — busy; a vanished dir counts old and the retry settles it.
|
|
259
|
+
return { acquired: false, kind: "busy", reason: busyUnidentified(lockDir) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Reclaim: quarantine-rename → post-rename re-judgment → ONE fresh-acquire retry.
|
|
263
|
+
hooks.beforeQuarantine?.();
|
|
264
|
+
const quarantine = `${lockDir}.stale-${pid.toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
265
|
+
let renamed = false;
|
|
266
|
+
try {
|
|
267
|
+
fs.rename(lockDir, quarantine);
|
|
268
|
+
renamed = true;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// ENOENT = a competing reclaimer moved it first — still take the one retry. Any other
|
|
271
|
+
// rename failure is genuine I/O and must not masquerade as contention.
|
|
272
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
273
|
+
renamed = false;
|
|
274
|
+
}
|
|
275
|
+
if (renamed) {
|
|
276
|
+
// Post-rename re-judgment on the MOVED state: between our judgment and the rename a
|
|
277
|
+
// competitor may have installed a successor claim (any operation id — never assume the
|
|
278
|
+
// one we are acquiring), or a winner may sit inside its mkdir↔first-write window (a
|
|
279
|
+
// young dir with no lease yet). Neither is ours to take: restore and report busy. Only
|
|
280
|
+
// the unchanged judged-stale state, a dead raced-in holder, or an AGED lease-less dir
|
|
281
|
+
// proceeds to the retry.
|
|
282
|
+
const moved = readLease(fs, quarantine);
|
|
283
|
+
let busyReason: string | null = null;
|
|
284
|
+
if (moved !== null) {
|
|
285
|
+
if (!sameLease(moved, observed) && isAlive(moved.pid)) {
|
|
286
|
+
busyReason = busyHolder(moved.pid, lockDir);
|
|
287
|
+
}
|
|
288
|
+
} else if (now() - lockDirBasisMs(fs, quarantine) < RECLAIM_GRACE_MS) {
|
|
289
|
+
// rename preserves mtime — the moved dir's age is the original dir's age.
|
|
290
|
+
busyReason = busyUnidentified(lockDir);
|
|
291
|
+
}
|
|
292
|
+
if (busyReason !== null) {
|
|
293
|
+
try {
|
|
294
|
+
fs.rename(quarantine, lockDir);
|
|
295
|
+
} catch {
|
|
296
|
+
// the name was retaken meanwhile — leave the quarantine; it self-heals as residue
|
|
297
|
+
}
|
|
298
|
+
return { acquired: false, kind: "busy", reason: busyReason };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
hooks.afterQuarantine?.();
|
|
302
|
+
const retried = tryFreshAcquire(fs, lockDir, pid, operationId);
|
|
303
|
+
if (renamed) {
|
|
304
|
+
try {
|
|
305
|
+
fs.rm(quarantine);
|
|
306
|
+
} catch {
|
|
307
|
+
// best-effort — a leftover quarantine dir is inert
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (retried !== null) return { acquired: true, token: retried };
|
|
311
|
+
return {
|
|
312
|
+
acquired: false,
|
|
313
|
+
kind: "busy",
|
|
314
|
+
reason:
|
|
315
|
+
`another session claimed the resolver lock at ${lockDir} first — dispatch from that ` +
|
|
316
|
+
"session, or retry once its claim clears",
|
|
317
|
+
};
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return {
|
|
320
|
+
acquired: false,
|
|
321
|
+
kind: "io_error",
|
|
322
|
+
reason: `resolver-claim filesystem failure at ${lockDir}: ${String(error)}`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Release THIS call's claim — the withheld-dispatch cleanup (a verified-increment failure must
|
|
329
|
+
* not leave a phantom holder). Token-fenced through a quarantine-verify: the claim is renamed
|
|
330
|
+
* to a private name first (atomic — a successor installed at the canonical path is never
|
|
331
|
+
* touched), verified against `token`, and deleted only when it proved ours; anything else is
|
|
332
|
+
* renamed back. Best-effort and never throws: leftover residue self-heals via the reclaim
|
|
333
|
+
* rules.
|
|
334
|
+
*/
|
|
335
|
+
export function releaseResolverClaim(
|
|
336
|
+
manifestPath: string,
|
|
337
|
+
token: string,
|
|
338
|
+
opts?: { fs?: Partial<LeaseFsOps> },
|
|
339
|
+
): void {
|
|
340
|
+
const fs: LeaseFsOps = { ...REAL_FS, ...(opts?.fs ?? {}) };
|
|
341
|
+
const lockDir = resolverLockDir(manifestPath);
|
|
342
|
+
const quarantine = `${lockDir}.release-${process.pid.toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
343
|
+
try {
|
|
344
|
+
try {
|
|
345
|
+
fs.rename(lockDir, quarantine);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (errorCode(error) === "ENOENT") return; // nothing to release
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
const moved = readLease(fs, quarantine);
|
|
351
|
+
if (moved !== null && moved.token === token) {
|
|
352
|
+
fs.rm(quarantine);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Not ours (a successor raced in) — put it back untouched.
|
|
356
|
+
try {
|
|
357
|
+
fs.rename(quarantine, lockDir);
|
|
358
|
+
} catch {
|
|
359
|
+
// the name was retaken meanwhile — leave the quarantine; it self-heals as residue
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
// best-effort — a leftover claim/quarantine goes stale and is reclaimed by the next acquire
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The session-data accessor seam (contracts.md §8.1).
|
|
1
|
+
// The session-data accessor seam (contracts.md §8.1) — RAW PRIMITIVES ONLY.
|
|
2
2
|
//
|
|
3
3
|
// Every run-scoped session artifact lives under `.perk/workflow/scratch/runs/<run_id>/data/`, and
|
|
4
4
|
// ALL session-data paths flow through this module (interior) or `perk/state/cache.py` (exterior) — the
|
|
@@ -6,59 +6,68 @@
|
|
|
6
6
|
// `scratch`/`runs` path segments anywhere else.
|
|
7
7
|
//
|
|
8
8
|
// Seam doctrine — degrade gracefully, never invent identity:
|
|
9
|
-
// - The current run_id resolves from the rebuilt `perk:workflow-state
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
9
|
+
// - The current run_id resolves from the rebuilt `perk:workflow-state`, narrowed non-empty AND
|
|
10
|
+
// safe as a path component (`isSafeRunId` — a hostile rebuilt id degrades to no-identity
|
|
11
|
+
// before any path derivation), and degrades to `null` when the session has no identity.
|
|
12
|
+
// CONTRAST with `coldDoor.activeRunId`, which falls back to a `cold-door-<ts>` stamp for
|
|
13
|
+
// stdin-staging debuggability: a stamp here would orphan data dirs and break run_id-keyed
|
|
14
|
+
// provenance, so this seam never stamps.
|
|
15
|
+
// - Ordinary reads return `null` on absence and I/O errors (with a loud stderr warning);
|
|
16
|
+
// opt-in strict reads classify ENOENT separately and refuse unsafe namespaces. Writes return
|
|
17
|
+
// the written path or `null` on failure (with a warning). Content operations never throw —
|
|
18
|
+
// a broken disk must not wedge a session.
|
|
19
|
+
// - The file primitives (`readSessionData`/`writeSessionData`/`ensureSessionDataDir`) take an
|
|
20
|
+
// EXPLICIT run id — identity is resolved ONCE (by the session engine, or here via
|
|
21
|
+
// `activeSessionRunId`) and passed down, so two independent identity reads can never
|
|
22
|
+
// disagree about which run's storage an operation touches.
|
|
16
23
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// pointer and fails open to `null` when validation refuses.
|
|
23
|
-
// - Validation always derives the path from `run_id` + `name` through the seam; the recorded
|
|
24
|
-
// `pointer.path` is informational/debugging only and is never dereferenced (workflow-state
|
|
25
|
-
// entries are reconstructable from untrusted session history).
|
|
26
|
-
// - The four lifecycle guarantees: REWIND ⇒ the rebuilt branch carries an older pointer while
|
|
27
|
-
// disk holds newer bytes ⇒ digest mismatch ⇒ refusal. FORK ⇒ the child run_id no longer
|
|
28
|
-
// matches the inherited pointer's ⇒ silent refusal (no inheritance; fresh dir).
|
|
29
|
-
// RELOAD/COMPACTION ⇒ same run_id ⇒ pointer + dir persist. CONCURRENT SESSIONS ⇒ run_id
|
|
30
|
-
// keying isolates dirs and pointers alike — validation never crosses run_ids.
|
|
24
|
+
// The ARTIFACT DISCIPLINE (provenance pointers, digest validation, the classified write/read
|
|
25
|
+
// tiers — contracts §8.1/§8.3) lives in the session engine, `session/workflowSession.ts`: this
|
|
26
|
+
// module supplies only the file mechanics its production binding
|
|
27
|
+
// (`session/branchWorkflowSession.ts`) builds its content port from. A bare file on disk is
|
|
28
|
+
// never trusted — consumers go through the `WorkflowSession` seam.
|
|
31
29
|
//
|
|
32
30
|
// Imports only node builtins + cache.ts + workflowState.ts + report.ts so the module stays
|
|
33
31
|
// loadable under `node --test`; accepts a minimal structural ctx (`BranchSource & { cwd }`).
|
|
34
32
|
|
|
35
33
|
import { createHash } from "node:crypto";
|
|
36
|
-
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
37
|
-
import { join
|
|
34
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
38
36
|
import type { ReportTarget } from "../surfaces/report.ts";
|
|
39
|
-
import { atomicWriteFileSync, ensureRunScratch, sessionDataDir } from "./cache.ts";
|
|
40
37
|
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} from "./workflowState.ts";
|
|
38
|
+
atomicWriteFileSync,
|
|
39
|
+
canonicalSessionDataDir,
|
|
40
|
+
ensureRunScratch,
|
|
41
|
+
isSafeRunId,
|
|
42
|
+
sessionDataDir,
|
|
43
|
+
} from "./cache.ts";
|
|
44
|
+
import { type BranchSource, branchOf, rebuildWorkflowState } from "./workflowState.ts";
|
|
45
|
+
|
|
46
|
+
export { canonicalSessionDataDir } from "./cache.ts";
|
|
48
47
|
|
|
49
48
|
/** Minimal context slice — `ExtensionContext` satisfies it (the `BranchSource` precedent). */
|
|
50
49
|
export interface SessionDataCtx extends BranchSource {
|
|
51
50
|
cwd: string;
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The composed context the session engine's production binding needs (`SessionDataCtx` for
|
|
55
|
+
* paths/branch + `ReportTarget` for the strict-append seam's loud failure reporting). Exported
|
|
56
|
+
* so `session/` consumes the reporting slice THROUGH this seam without importing `surfaces/`
|
|
57
|
+
* directly.
|
|
58
|
+
*/
|
|
59
|
+
export type SessionArtifactCtx = SessionDataCtx & ReportTarget;
|
|
60
|
+
|
|
54
61
|
/**
|
|
55
62
|
* The current session's run_id from the rebuilt workflow-state; `null` when the session has no
|
|
56
|
-
* identity (no stamp fallback — see the header)
|
|
63
|
+
* identity (no stamp fallback — see the header) OR when the rebuilt id is unsafe as a path
|
|
64
|
+
* component (the read-path trust boundary: `isSafeRunId` — an unsafe persisted id must never
|
|
65
|
+
* reach a path derivation or a receipt).
|
|
57
66
|
*/
|
|
58
67
|
export function activeSessionRunId(ctx: SessionDataCtx): string | null {
|
|
59
68
|
try {
|
|
60
69
|
const runId = rebuildWorkflowState(branchOf(ctx)).run_id;
|
|
61
|
-
if (typeof runId === "string" && runId
|
|
70
|
+
if (typeof runId === "string" && isSafeRunId(runId)) return runId;
|
|
62
71
|
} catch {
|
|
63
72
|
// a throwing getBranch means no resolvable identity — degrade to null
|
|
64
73
|
}
|
|
@@ -72,13 +81,16 @@ export function activeSessionDataDir(ctx: SessionDataCtx): string | null {
|
|
|
72
81
|
return sessionDataDir(ctx.cwd, runId);
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Ensure the validated run root, then its data dir, for an EXPLICIT run id; `null` + a warning
|
|
86
|
+
* on failure (an unsafe id is refused loudly by the write path's `ensureRunScratch`). The run
|
|
87
|
+
* identity is the CALLER's: the session engine passes its one validated id so storage, pointer,
|
|
88
|
+
* and receipt can never disagree.
|
|
89
|
+
*/
|
|
90
|
+
export function ensureSessionDataDir(cwd: string, runId: string): string | null {
|
|
91
|
+
const dir = sessionDataDir(cwd, runId);
|
|
80
92
|
try {
|
|
81
|
-
ensureRunScratch(
|
|
93
|
+
ensureRunScratch(cwd, runId);
|
|
82
94
|
mkdirSync(dir, { recursive: true });
|
|
83
95
|
} catch (error) {
|
|
84
96
|
console.error(`perk: warning: could not create session data dir ${dir}: ${error}`);
|
|
@@ -88,13 +100,11 @@ export function ensureSessionDataDir(ctx: SessionDataCtx): string | null {
|
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
/**
|
|
91
|
-
* Read a session-data file; `null` on
|
|
92
|
-
*
|
|
103
|
+
* Read a run's session-data file; `null` on an absent file (normal, branchable) and on read
|
|
104
|
+
* errors (with a stderr warning). Never throws.
|
|
93
105
|
*/
|
|
94
|
-
export function readSessionData(
|
|
95
|
-
const
|
|
96
|
-
if (dir === null) return null;
|
|
97
|
-
const path = join(dir, name);
|
|
106
|
+
export function readSessionData(cwd: string, runId: string, name: string): string | null {
|
|
107
|
+
const path = join(sessionDataDir(cwd, runId), name);
|
|
98
108
|
if (!existsSync(path)) return null;
|
|
99
109
|
try {
|
|
100
110
|
return readFileSync(path, "utf8");
|
|
@@ -104,16 +114,39 @@ export function readSessionData(ctx: SessionDataCtx, name: string): string | nul
|
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
export type SessionDataRead =
|
|
118
|
+
| { status: "found"; content: string }
|
|
119
|
+
| { status: "absent" }
|
|
120
|
+
| { status: "io-error" };
|
|
121
|
+
|
|
122
|
+
/** Strict content port: only ENOENT is absent; unsafe redirects and non-files are I/O refusals. */
|
|
123
|
+
export function readSessionDataStrict(cwd: string, runId: string, name: string): SessionDataRead {
|
|
124
|
+
try {
|
|
125
|
+
if (!isSafeRunId(name)) return { status: "io-error" };
|
|
126
|
+
const dir = canonicalSessionDataDir(cwd, runId, { create: false });
|
|
127
|
+
if (dir === null) return { status: "absent" };
|
|
128
|
+
const path = join(dir, name);
|
|
129
|
+
if (!lstatSync(path).isFile()) return { status: "io-error" };
|
|
130
|
+
return { status: "found", content: readFileSync(path, "utf8") };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
133
|
+
return { status: "absent" };
|
|
134
|
+
}
|
|
135
|
+
return { status: "io-error" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
/**
|
|
108
|
-
* Write a session-data file (creating the data dir lazily); returns the absolute path,
|
|
109
|
-
* `null` + a stderr warning on any failure. Never throws.
|
|
140
|
+
* Write a run's session-data file (creating the data dir lazily); returns the absolute path,
|
|
141
|
+
* or `null` + a stderr warning on any failure. Never throws.
|
|
110
142
|
*/
|
|
111
143
|
export function writeSessionData(
|
|
112
|
-
|
|
144
|
+
cwd: string,
|
|
145
|
+
runId: string,
|
|
113
146
|
name: string,
|
|
114
147
|
content: string,
|
|
115
148
|
): string | null {
|
|
116
|
-
const dir = ensureSessionDataDir(
|
|
149
|
+
const dir = ensureSessionDataDir(cwd, runId);
|
|
117
150
|
if (dir === null) return null;
|
|
118
151
|
const path = join(dir, name);
|
|
119
152
|
try {
|
|
@@ -126,106 +159,6 @@ export function writeSessionData(
|
|
|
126
159
|
}
|
|
127
160
|
|
|
128
161
|
/** The session-artifact digest convention: `sha256:` + lowercase hex of the UTF-8 bytes. */
|
|
129
|
-
export function digestSessionData(content: string): string {
|
|
130
|
-
return `sha256:${createHash("sha256").update(content
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Per-name pointer identity: same run_id + same digest (the custom-equals comparator). */
|
|
134
|
-
function artifactMapsEqual(
|
|
135
|
-
rebuilt: Record<string, SessionArtifactPointer> | null | undefined,
|
|
136
|
-
expected: Record<string, SessionArtifactPointer> | null | undefined,
|
|
137
|
-
): boolean {
|
|
138
|
-
const a = rebuilt ?? {};
|
|
139
|
-
const b = expected ?? {};
|
|
140
|
-
const names = Object.keys(b);
|
|
141
|
-
if (Object.keys(a).length !== names.length) return false;
|
|
142
|
-
return names.every(
|
|
143
|
-
(name) => a[name]?.run_id === b[name]?.run_id && a[name]?.digest === b[name]?.digest,
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Write a session artifact AND record its provenance pointer in `perk:workflow-state`.
|
|
149
|
-
* Returns the absolute written path only when the artifact is *fully recorded* (file written,
|
|
150
|
-
* read back, digested, pointer strict-appended); `null` on any failure — the seam/module has
|
|
151
|
-
* already warned, and an orphan file (pointer-append failure) is gitignored scratch for the
|
|
152
|
-
* GC to prune. Never throws.
|
|
153
|
-
*/
|
|
154
|
-
export function writeSessionArtifact(
|
|
155
|
-
sink: EntrySink,
|
|
156
|
-
ctx: SessionDataCtx & ReportTarget,
|
|
157
|
-
name: string,
|
|
158
|
-
content: string,
|
|
159
|
-
): string | null {
|
|
160
|
-
const written = writeSessionData(ctx, name, content);
|
|
161
|
-
if (written === null) return null; // already warned; never point at an unwritten file
|
|
162
|
-
|
|
163
|
-
// Digest the bytes as read back from disk — catches encoding/disk surprises.
|
|
164
|
-
const readBack = readSessionData(ctx, name);
|
|
165
|
-
if (readBack === null) {
|
|
166
|
-
console.error(`perk: warning: session artifact ${written} unreadable after write`);
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const runId = activeSessionRunId(ctx);
|
|
171
|
-
if (runId === null) return null; // unreachable after a successful write; belt-and-braces
|
|
172
|
-
const pointer: SessionArtifactPointer = {
|
|
173
|
-
run_id: runId,
|
|
174
|
-
name,
|
|
175
|
-
path: relative(ctx.cwd, written),
|
|
176
|
-
digest: digestSessionData(readBack),
|
|
177
|
-
at: new Date().toISOString(),
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
// Per-field LWW: each append must carry the WHOLE merged map so sibling artifacts survive.
|
|
181
|
-
const merged: Record<string, SessionArtifactPointer> = {
|
|
182
|
-
...(rebuildWorkflowState(branchOf(ctx)).session_artifacts ?? {}),
|
|
183
|
-
[name]: pointer,
|
|
184
|
-
};
|
|
185
|
-
const ok = appendWorkflowState(sink, ctx, {
|
|
186
|
-
data: { session_artifacts: merged },
|
|
187
|
-
field: "session_artifacts",
|
|
188
|
-
expected: merged,
|
|
189
|
-
scope: "session-data",
|
|
190
|
-
failure: `session_artifacts pointer read-back failed for ${name}`,
|
|
191
|
-
equals: artifactMapsEqual,
|
|
192
|
-
});
|
|
193
|
-
return ok ? written : null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Read a session artifact through its provenance pointer; fail-open `null` when validation
|
|
198
|
-
* refuses. Tiering: no identity / no pointer / run_id mismatch (the designed fork-isolation
|
|
199
|
-
* path) → silent `null`; pointer matches but the file is absent, unreadable, or its digest
|
|
200
|
-
* differs (rewind, tamper) → stderr warning + `null`. The path is always DERIVED from
|
|
201
|
-
* `run_id` + `name` via the seam — `pointer.path` is never dereferenced. Never throws.
|
|
202
|
-
*/
|
|
203
|
-
export function readSessionArtifact(
|
|
204
|
-
ctx: SessionDataCtx,
|
|
205
|
-
name: string,
|
|
206
|
-
): { path: string; content: string } | null {
|
|
207
|
-
const runId = activeSessionRunId(ctx);
|
|
208
|
-
if (runId === null) return null;
|
|
209
|
-
let pointer: SessionArtifactPointer | undefined;
|
|
210
|
-
try {
|
|
211
|
-
pointer = rebuildWorkflowState(branchOf(ctx)).session_artifacts?.[name];
|
|
212
|
-
} catch {
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
if (pointer === undefined) return null;
|
|
216
|
-
if (pointer.run_id !== runId) return null; // fork / concurrent isolation — by design, silent
|
|
217
|
-
|
|
218
|
-
const path = join(sessionDataDir(ctx.cwd, runId), name);
|
|
219
|
-
const content = readSessionData(ctx, name);
|
|
220
|
-
if (content === null) {
|
|
221
|
-
console.error(`perk: warning: session artifact ${name} has a pointer but no file at ${path}`);
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
if (digestSessionData(content) !== pointer.digest) {
|
|
225
|
-
console.error(
|
|
226
|
-
`perk: warning: session artifact ${path} digest mismatch (rewound or modified) — refusing`,
|
|
227
|
-
);
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
return { path, content };
|
|
162
|
+
export function digestSessionData(content: string | Uint8Array): string {
|
|
163
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
231
164
|
}
|