pi-daddy 0.21.0 → 0.22.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/CHANGELOG.md +25 -1
- package/README.md +14 -9
- package/contracts/ledger/v3/fixtures/capability-decision.json +3 -4
- package/contracts/ledger/v3/fixtures/check-receipt.json +3 -4
- package/contracts/ledger/v3/fixtures/child-lifecycle.json +3 -4
- package/contracts/ledger/v3/fixtures/workflow-fact.json +3 -4
- package/contracts/ledger/v3/fixtures/workspace-lease.json +3 -4
- package/contracts/ledger/v3/ledger-event.schema.json +38 -13
- package/dist/check-runner.d.ts.map +1 -1
- package/dist/check-runner.js +161 -149
- package/dist/check-runner.js.map +1 -1
- package/dist/correlation.d.ts +15 -4
- package/dist/correlation.d.ts.map +1 -1
- package/dist/correlation.js +40 -2
- package/dist/correlation.js.map +1 -1
- package/dist/delegation-approval.d.ts +2 -2
- package/dist/delegation-approval.d.ts.map +1 -1
- package/dist/delegation-approval.js +2 -0
- package/dist/delegation-approval.js.map +1 -1
- package/dist/finalization.d.ts +15 -0
- package/dist/finalization.d.ts.map +1 -0
- package/dist/finalization.js +65 -0
- package/dist/finalization.js.map +1 -0
- package/dist/git-identity.d.ts.map +1 -1
- package/dist/git-identity.js +18 -15
- package/dist/git-identity.js.map +1 -1
- package/dist/grant-store.d.ts +13 -0
- package/dist/grant-store.d.ts.map +1 -1
- package/dist/grant-store.js +48 -25
- package/dist/grant-store.js.map +1 -1
- package/dist/herdr-poll.js +1 -1
- package/dist/herdr-poll.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +2 -6
- package/dist/init.js.map +1 -1
- package/dist/model-preflight.d.ts +8 -0
- package/dist/model-preflight.d.ts.map +1 -0
- package/dist/model-preflight.js +20 -0
- package/dist/model-preflight.js.map +1 -0
- package/dist/refusals.d.ts +1 -1
- package/dist/refusals.d.ts.map +1 -1
- package/dist/refusals.js +2 -0
- package/dist/refusals.js.map +1 -1
- package/dist/run-child.d.ts +1 -1
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +2 -2
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +6 -5
- package/dist/run-herdr.js.map +1 -1
- package/extensions/chain-plan.ts +16 -0
- package/extensions/correlation-shape.ts +39 -0
- package/extensions/delegate-chain.ts +9 -14
- package/extensions/delegation.ts +3 -25
- package/extensions/execute-child.ts +8 -1
- package/extensions/grant-store-refusal.ts +36 -0
- package/extensions/grants.ts +6 -0
- package/extensions/run-delegation.ts +21 -2
- package/extensions/session.ts +15 -5
- package/extensions/stored-grant-session.ts +33 -0
- package/package.json +3 -1
- package/src/check-runner.ts +56 -44
- package/src/correlation.ts +55 -6
- package/src/delegation-approval.ts +4 -2
- package/src/finalization.ts +79 -0
- package/src/git-identity.ts +20 -16
- package/src/grant-store.ts +55 -25
- package/src/herdr-poll.ts +1 -1
- package/src/init.ts +5 -5
- package/src/model-preflight.ts +32 -0
- package/src/refusals.ts +2 -0
- package/src/run-child.ts +2 -2
- package/src/run-herdr.ts +6 -4
package/extensions/grants.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { planWithApprovals } from "./run-delegation.ts";
|
|
|
40
40
|
import { createGrantsSession, loadProjectDefinitions, resolveExecutor, type GrantsSession } from "./session.ts";
|
|
41
41
|
import { reportSessionStart } from "./session-report.ts";
|
|
42
42
|
import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
|
|
43
|
+
import { reportGrantStoreRefusal } from "./grant-store-refusal.ts";
|
|
43
44
|
import {
|
|
44
45
|
defaultDashboardPaths,
|
|
45
46
|
offerDashboardHandshake,
|
|
@@ -93,6 +94,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
94
|
);
|
|
94
95
|
}
|
|
95
96
|
try {
|
|
97
|
+
try {
|
|
98
|
+
await reportGrantStoreRefusal(session, ctx.ui);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
ctx.ui.notify(`grants: stored-grant refusal reporting failed (${String(error)}); the session remains refused.`, "error");
|
|
101
|
+
}
|
|
96
102
|
// Guarded together, and guarded at all because of R-60 rather than because either one throws today:
|
|
97
103
|
// both loaders swallow their own filesystem errors, so this catch is currently unreachable. The point
|
|
98
104
|
// is that "currently" is not a property anyone can rely on — `verifyLedger` was also harmless until
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import type { InheritableApproval } from "../src/approval.ts";
|
|
25
25
|
import type { GrantsSession } from "./session.ts";
|
|
26
26
|
import type { CorrelationMetadata } from "../src/correlation.ts";
|
|
27
|
+
import { preflightModel, type ModelCatalogue } from "../src/model-preflight.ts";
|
|
27
28
|
import { GovernanceRefusal, refusal as structuredRefusal } from "../src/refusals.ts";
|
|
28
29
|
import { executePlannedChild, type DelegationOutcome } from "./execute-child.ts";
|
|
29
30
|
import { recordDelegationDecision, type ApprovalLedgerFacts } from "./delegation-ledger.ts";
|
|
@@ -57,6 +58,7 @@ interface ChildSpec {
|
|
|
57
58
|
interface DelegationToolContext extends ApprovalUIContext {
|
|
58
59
|
cwd: string;
|
|
59
60
|
model?: { provider: string; id: string };
|
|
61
|
+
modelRegistry: ModelCatalogue;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
/** A planned delegation, plus whatever approvals contributed to it. */
|
|
@@ -274,11 +276,17 @@ export async function runOneDelegation(
|
|
|
274
276
|
// requested and refused, and stored approvals still count toward it — nothing is *hidden*, only nobody is
|
|
275
277
|
// *asked*. It is the same argument `/grants` uses for its preview.
|
|
276
278
|
const executorRefusal = session.executor.refusal;
|
|
279
|
+
const modelRefusal = preflightModel(
|
|
280
|
+
request.model,
|
|
281
|
+
ctx.modelRegistry,
|
|
282
|
+
session.modelResolutionCache,
|
|
283
|
+
session.allowUnresolvedModels,
|
|
284
|
+
);
|
|
277
285
|
let preparedWorkspace: PreparedWorkspace | undefined;
|
|
278
286
|
let approvalOutcome: ApprovalOutcome | undefined;
|
|
279
287
|
let plan: ReturnType<typeof planDelegation>;
|
|
280
288
|
|
|
281
|
-
if (spec.workspace && !executorRefusal) {
|
|
289
|
+
if (spec.workspace && !executorRefusal && !modelRefusal) {
|
|
282
290
|
// Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
|
|
283
291
|
// preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
|
|
284
292
|
// no child process.
|
|
@@ -307,7 +315,14 @@ export async function runOneDelegation(
|
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
317
|
} else {
|
|
310
|
-
const gated = await planWithApprovals(
|
|
318
|
+
const gated = await planWithApprovals(
|
|
319
|
+
session,
|
|
320
|
+
request,
|
|
321
|
+
extra,
|
|
322
|
+
executorRefusal || modelRefusal ? null : ctx,
|
|
323
|
+
signal,
|
|
324
|
+
preApproved,
|
|
325
|
+
);
|
|
311
326
|
plan = gated.plan;
|
|
312
327
|
approvalOutcome = gated.approval;
|
|
313
328
|
}
|
|
@@ -315,6 +330,10 @@ export async function runOneDelegation(
|
|
|
315
330
|
if (executorRefusal) {
|
|
316
331
|
const message = `grants: ${executorRefusal}`;
|
|
317
332
|
plan = { ...plan, ok: false, reason: message, refusal: structuredRefusal("EXECUTOR_UNAVAILABLE", message) };
|
|
333
|
+
} else if (modelRefusal) {
|
|
334
|
+
// This is a routing preflight, not a grant decision: preserve the resolved capability facts and replace
|
|
335
|
+
// only the outcome. It is still recorded by the common refusal path, and no lease, dialog or child starts.
|
|
336
|
+
plan = { ...plan, ok: false, reason: modelRefusal.message, refusal: modelRefusal };
|
|
318
337
|
}
|
|
319
338
|
|
|
320
339
|
// The capability decision provisions the child, so this append remains load-bearing and fails closed.
|
package/extensions/session.ts
CHANGED
|
@@ -44,8 +44,10 @@ import type { Capability } from "../src/resolve.ts";
|
|
|
44
44
|
import { loadDefinitions } from "../src/definitions.ts";
|
|
45
45
|
import { buildCatalog } from "../src/catalog.ts";
|
|
46
46
|
import { ENV_WORKSPACE_REGISTRY } from "../src/workspace.ts";
|
|
47
|
-
import {
|
|
47
|
+
import type { GrantStoreRefusalReason } from "../src/grant-store.ts";
|
|
48
48
|
import { republishable } from "./approvals.ts";
|
|
49
|
+
import { storedGrantSessionState } from "./stored-grant-session.ts";
|
|
50
|
+
import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/model-preflight.ts";
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* Run governed children in herdr panes instead of captured child processes.
|
|
@@ -118,6 +120,10 @@ export interface GrantsSession {
|
|
|
118
120
|
readonly fanoutBudget: number;
|
|
119
121
|
/** Whether `delegate` / `delegate_all` are registered at all (S-5). Decided on the INHERITED grant. */
|
|
120
122
|
readonly mayDelegate: boolean;
|
|
123
|
+
/** Operator escape hatch for custom model resolution. Exact `1`, read once for the session. */
|
|
124
|
+
readonly allowUnresolvedModels: boolean;
|
|
125
|
+
/** Results from pi's synchronous model catalogue, shared by every delegation in this session. */
|
|
126
|
+
readonly modelResolutionCache: Map<string, boolean>;
|
|
121
127
|
/** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
|
|
122
128
|
readonly extensionPath?: string;
|
|
123
129
|
|
|
@@ -183,6 +189,8 @@ export interface GrantsSession {
|
|
|
183
189
|
* `createGrantsSession` for why the factory cannot use `ctx.cwd`.
|
|
184
190
|
*/
|
|
185
191
|
readonly storeCwd: string;
|
|
192
|
+
/** Invalid stored state fails closed and is reported/ledgered during session_start. */
|
|
193
|
+
readonly grantStoreRefusal?: { reason: GrantStoreRefusalReason; path: string };
|
|
186
194
|
/**
|
|
187
195
|
* Adopt the project choice made DURING the session — grant plus optional default ledger — without restart.
|
|
188
196
|
*
|
|
@@ -260,14 +268,13 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
|
|
|
260
268
|
const storeCwd = process.cwd();
|
|
261
269
|
// One root-only store read supplies both decisions made by `/grants init`. A child always has ENV_GRANT,
|
|
262
270
|
// so it cannot activate a ledger merely because its routed cwd happens to have a v2 store (ADR-0037).
|
|
263
|
-
const
|
|
264
|
-
const governed
|
|
265
|
-
const inherited: Capability[] = grantRaw !== undefined ? parseList(grantRaw) : (stored?.grant ?? [WILDCARD]);
|
|
271
|
+
const storedState = storedGrantSessionState(grantRaw, storeCwd);
|
|
272
|
+
const { governed, inherited, refusal: grantStoreRefusal } = storedState;
|
|
266
273
|
const ledgerRaw = process.env[ENV_LEDGER];
|
|
267
274
|
// Capture provenance before publishChildEnv writes this session's derived default into process.env. A later
|
|
268
275
|
// `/grants init` for ctx.cwd must not mistake our own publication for an operator override.
|
|
269
276
|
const ledgerFromEnvironment = ledgerRaw !== undefined;
|
|
270
|
-
const storedLedger =
|
|
277
|
+
const storedLedger = storedState.defaultLedger;
|
|
271
278
|
// G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield
|
|
272
279
|
// `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off.
|
|
273
280
|
const bounds = depthConfig(process.env[ENV_DEPTH], process.env[ENV_MAX_DEPTH]);
|
|
@@ -310,6 +317,8 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
|
|
|
310
317
|
* before any tools are observed. An ungoverned session registers it as before.
|
|
311
318
|
*/
|
|
312
319
|
mayDelegate: !governed || inherited.includes(DELEGATE_CAPABILITY) || inherited.includes(WILDCARD),
|
|
320
|
+
allowUnresolvedModels: process.env[ENV_ALLOW_UNRESOLVED_MODELS] === "1",
|
|
321
|
+
modelResolutionCache: new Map<string, boolean>(),
|
|
313
322
|
extensionPath,
|
|
314
323
|
|
|
315
324
|
sessionApprovals: new Set<string>(),
|
|
@@ -350,6 +359,7 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
|
|
|
350
359
|
}),
|
|
351
360
|
|
|
352
361
|
storeCwd,
|
|
362
|
+
...(grantStoreRefusal ? { grantStoreRefusal } : {}),
|
|
353
363
|
|
|
354
364
|
adoptGrant: (grant: Capability[], projectLedger?: string) => {
|
|
355
365
|
// Governed too, not just bounded. A session that starts with no grant and then runs `/grants init` is
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
grantStorePath,
|
|
3
|
+
loadStoredGrantStateSync,
|
|
4
|
+
projectLedgerPath,
|
|
5
|
+
type GrantStoreRefusalReason,
|
|
6
|
+
type StoredGrant,
|
|
7
|
+
} from "../src/grant-store.ts";
|
|
8
|
+
import { WILDCARD } from "../src/pi-tools.ts";
|
|
9
|
+
import { parseList } from "../src/propagation.ts";
|
|
10
|
+
import type { Capability } from "../src/resolve.ts";
|
|
11
|
+
|
|
12
|
+
export interface StoredGrantSessionState {
|
|
13
|
+
stored?: StoredGrant;
|
|
14
|
+
refusal?: { reason: GrantStoreRefusalReason; path: string };
|
|
15
|
+
governed: boolean;
|
|
16
|
+
inherited: Capability[];
|
|
17
|
+
defaultLedger?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve the root-only project store while preserving the environment's absolute precedence. */
|
|
21
|
+
export function storedGrantSessionState(grantRaw: string | undefined, cwd: string): StoredGrantSessionState {
|
|
22
|
+
const state = grantRaw === undefined ? loadStoredGrantStateSync(cwd) : { state: "absent" as const };
|
|
23
|
+
const stored = state.state === "valid" ? state.stored : undefined;
|
|
24
|
+
const refusal = state.state === "refuse" ? { reason: state.reason, path: grantStorePath(cwd) } : undefined;
|
|
25
|
+
return {
|
|
26
|
+
...(stored ? { stored } : {}),
|
|
27
|
+
...(refusal ? { refusal } : {}),
|
|
28
|
+
governed: grantRaw !== undefined || stored !== undefined || refusal !== undefined,
|
|
29
|
+
inherited: grantRaw !== undefined ? parseList(grantRaw) : stored?.grant ?? (refusal ? [] : [WILDCARD]),
|
|
30
|
+
// Invalid state authorises only a refusal line at this conventional path; no child can start from it.
|
|
31
|
+
defaultLedger: stored?.projectLedger || refusal ? projectLedgerPath(cwd) : undefined,
|
|
32
|
+
};
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -161,6 +161,7 @@
|
|
|
161
161
|
"typecheck": "tsc -p tsconfig.check.json",
|
|
162
162
|
"test": "node --test test/*.test.ts",
|
|
163
163
|
"test:integration": "node --test test-integration/*.it.ts",
|
|
164
|
+
"test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts",
|
|
164
165
|
"contracts:generate": "node scripts/generate-ledger-v3-contract.ts",
|
|
165
166
|
"prepack": "npm run build",
|
|
166
167
|
"test:smoke": "node scripts/smoke-installed.mjs",
|
|
@@ -175,6 +176,7 @@
|
|
|
175
176
|
},
|
|
176
177
|
"peerDependencies": {
|
|
177
178
|
"@earendil-works/pi-coding-agent": ">=0.83.0",
|
|
179
|
+
"@types/node": ">=22",
|
|
178
180
|
"typebox": ">=1.0.0"
|
|
179
181
|
},
|
|
180
182
|
"devDependencies": {
|
package/src/check-runner.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, isAbsolute, join } from "node:path";
|
|
5
5
|
import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
|
|
6
|
+
import { runWithFinalizers } from "./finalization.ts";
|
|
6
7
|
import {
|
|
7
8
|
appendLedgerEvent,
|
|
8
9
|
buildCheckReceiptLedgerEvent,
|
|
@@ -163,27 +164,28 @@ export async function runNamedCheck(input: {
|
|
|
163
164
|
/** Failures of best-effort RECORDS, reported alongside whatever actually happened — never instead of it. */
|
|
164
165
|
const notes: string[] = [];
|
|
165
166
|
let releaseReason = "failed";
|
|
166
|
-
|
|
167
|
+
return runWithFinalizers(async () => {
|
|
167
168
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
169
|
+
try {
|
|
170
|
+
lease = await acquireWorkspaceLease({
|
|
171
|
+
workspace: input.workspace, access: leaseAccess, leaseDir: input.leaseDir ?? defaultWorkspaceLeaseDir(),
|
|
172
|
+
ownerId, signal: input.signal,
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (input.ledgerPath) await appendLedgerEvent(
|
|
176
|
+
{ path: input.ledgerPath, strict: true },
|
|
177
|
+
buildWorkspaceLeaseEvent({
|
|
178
|
+
executionId, parentExecutionId,
|
|
179
|
+
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
180
|
+
outcome: "refused",
|
|
181
|
+
correlation, now: new Date(),
|
|
182
|
+
...(error instanceof GovernanceRefusal
|
|
183
|
+
? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
|
|
184
|
+
: {}),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
187
189
|
if (input.ledgerPath) {
|
|
188
190
|
try {
|
|
189
191
|
await appendLedgerEvent(
|
|
@@ -329,29 +331,39 @@ export async function runNamedCheck(input: {
|
|
|
329
331
|
);
|
|
330
332
|
}
|
|
331
333
|
throw error;
|
|
332
|
-
} finally {
|
|
333
|
-
if (stagedDir) await rm(stagedDir, { recursive: true, force: true });
|
|
334
|
-
if (lease) {
|
|
335
|
-
// `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
|
|
336
|
-
// above: a `finally` that throws destroys whatever the caller was already returning or raising.
|
|
337
|
-
const outcome = await lease.release(releaseReason);
|
|
338
|
-
if (input.ledgerPath) await appendLedgerEvent(
|
|
339
|
-
{
|
|
340
|
-
path: input.ledgerPath,
|
|
341
|
-
strict: false,
|
|
342
|
-
onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
|
|
343
|
-
},
|
|
344
|
-
buildWorkspaceLeaseEvent({
|
|
345
|
-
executionId, parentExecutionId,
|
|
346
|
-
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
347
|
-
outcome: outcome === "lost"
|
|
348
|
-
? "lost"
|
|
349
|
-
: outcome === "released-unrecorded"
|
|
350
|
-
? "released-unrecorded"
|
|
351
|
-
: releaseReason === "timeout" ? "timeout" : "released",
|
|
352
|
-
releaseReason, correlation, now: new Date(),
|
|
353
|
-
}),
|
|
354
|
-
);
|
|
355
334
|
}
|
|
356
|
-
}
|
|
335
|
+
}, [
|
|
336
|
+
{
|
|
337
|
+
label: "staged check cleanup failed",
|
|
338
|
+
run: async () => {
|
|
339
|
+
if (stagedDir) await rm(stagedDir, { recursive: true, force: true });
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
label: "workspace lease finalizer failed",
|
|
344
|
+
run: async () => {
|
|
345
|
+
if (!lease) return;
|
|
346
|
+
// `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
|
|
347
|
+
// above: a finalizer must not destroy whatever the caller was already returning or raising.
|
|
348
|
+
const outcome = await lease.release(releaseReason);
|
|
349
|
+
if (input.ledgerPath) await appendLedgerEvent(
|
|
350
|
+
{
|
|
351
|
+
path: input.ledgerPath,
|
|
352
|
+
strict: false,
|
|
353
|
+
onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
|
|
354
|
+
},
|
|
355
|
+
buildWorkspaceLeaseEvent({
|
|
356
|
+
executionId, parentExecutionId,
|
|
357
|
+
childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
|
|
358
|
+
outcome: outcome === "lost"
|
|
359
|
+
? "lost"
|
|
360
|
+
: outcome === "released-unrecorded"
|
|
361
|
+
? "released-unrecorded"
|
|
362
|
+
: releaseReason === "timeout" ? "timeout" : "released",
|
|
363
|
+
releaseReason, correlation, now: new Date(),
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
]);
|
|
357
369
|
}
|
package/src/correlation.ts
CHANGED
|
@@ -7,12 +7,18 @@ import { isLedgerCorrelationIdentifier } from "./ledger-identifiers.ts";
|
|
|
7
7
|
export type JsonPrimitive = string | number | boolean | null;
|
|
8
8
|
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
9
9
|
|
|
10
|
+
export interface AssuranceScope {
|
|
11
|
+
type: "entire-run" | "selectors";
|
|
12
|
+
selectors: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Non-authoritative workflow metadata copied onto runtime and ledger events.
|
|
12
17
|
*
|
|
13
|
-
* pi-daddy
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* pi-daddy interprets only the pinned correlation wire contract: schema version 1.0 and the closed
|
|
19
|
+
* assurance-scope shape. Labels, selector meanings, timestamps and sequence floors remain non-authoritative.
|
|
20
|
+
* In particular, a digest-looking value here never substitutes for a digest computed by the planner. The
|
|
21
|
+
* snake_case names are the wire vocabulary used by external controllers.
|
|
16
22
|
*/
|
|
17
23
|
export interface CorrelationMetadata {
|
|
18
24
|
schema_version?: string;
|
|
@@ -25,7 +31,7 @@ export interface CorrelationMetadata {
|
|
|
25
31
|
assurance_effective?: string;
|
|
26
32
|
policy_label?: string;
|
|
27
33
|
assurance_source?: string;
|
|
28
|
-
assurance_scope?:
|
|
34
|
+
assurance_scope?: AssuranceScope;
|
|
29
35
|
activated_at?: string;
|
|
30
36
|
plan_digest?: string;
|
|
31
37
|
definition_digest?: string;
|
|
@@ -93,6 +99,29 @@ function correlationTooLarge(message: string, details?: Record<string, string |
|
|
|
93
99
|
return new GovernanceRefusal(refusal("CORRELATION_TOO_LARGE", `correlation metadata: ${message}`, details));
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
function normaliseAssuranceScope(value: unknown): AssuranceScope {
|
|
103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
104
|
+
throw correlationRefusal("assurance_scope must be an object with type and selectors");
|
|
105
|
+
}
|
|
106
|
+
const scope = value as Record<string, unknown>;
|
|
107
|
+
const keys = Object.keys(scope).sort();
|
|
108
|
+
if (keys.length !== 2 || keys[0] !== "selectors" || keys[1] !== "type") {
|
|
109
|
+
throw correlationRefusal("assurance_scope must contain only type and selectors");
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(scope.selectors) || !scope.selectors.every((selector) => typeof selector === "string" && selector.length > 0)) {
|
|
112
|
+
throw correlationRefusal("assurance_scope selectors must be non-empty strings");
|
|
113
|
+
}
|
|
114
|
+
if (scope.type === "entire-run") {
|
|
115
|
+
if (scope.selectors.length !== 0) throw correlationRefusal("entire-run assurance_scope requires empty selectors");
|
|
116
|
+
return { type: "entire-run", selectors: [] };
|
|
117
|
+
}
|
|
118
|
+
if (scope.type === "selectors") {
|
|
119
|
+
if (scope.selectors.length === 0) throw correlationRefusal("selectors scope requires at least one selector");
|
|
120
|
+
return { type: "selectors", selectors: [...scope.selectors] };
|
|
121
|
+
}
|
|
122
|
+
throw correlationRefusal("assurance_scope type must be entire-run or selectors");
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
/**
|
|
97
126
|
* Snapshot bounded JSON metadata so a caller cannot mutate a record after planning, and so nothing
|
|
98
127
|
* unbounded or undeclared can reach the ledger through it.
|
|
@@ -138,7 +167,7 @@ export function normaliseCorrelation(input: CorrelationMetadata | undefined): Co
|
|
|
138
167
|
{ limit: MAX_CORRELATION_SCOPE_BYTES, actual: size },
|
|
139
168
|
);
|
|
140
169
|
}
|
|
141
|
-
output[key] = value;
|
|
170
|
+
output[key] = normaliseAssuranceScope(value);
|
|
142
171
|
continue;
|
|
143
172
|
}
|
|
144
173
|
if (CORRELATION_NUMERIC.has(key as keyof CorrelationMetadata)) {
|
|
@@ -149,6 +178,12 @@ export function normaliseCorrelation(input: CorrelationMetadata | undefined): Co
|
|
|
149
178
|
continue;
|
|
150
179
|
}
|
|
151
180
|
if (typeof value !== "string") throw correlationRefusal(`${key} must be a string`, { field: key });
|
|
181
|
+
if (key === "schema_version" && value !== "1.0") {
|
|
182
|
+
throw correlationRefusal(`unsupported schema_version ${value}; supported version is 1.0`, {
|
|
183
|
+
field: key,
|
|
184
|
+
supported: "1.0",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
152
187
|
if (value.length > MAX_CORRELATION_FIELD_CHARS) {
|
|
153
188
|
throw correlationTooLarge(
|
|
154
189
|
`${key} exceeds ${MAX_CORRELATION_FIELD_CHARS} characters`,
|
|
@@ -183,6 +218,8 @@ export interface ApprovalBinding {
|
|
|
183
218
|
definition_sha256?: string;
|
|
184
219
|
workspace_id?: string;
|
|
185
220
|
context_id?: string;
|
|
221
|
+
tree_sha?: string;
|
|
222
|
+
last_change_seq?: number;
|
|
186
223
|
parent_id: string;
|
|
187
224
|
}
|
|
188
225
|
|
|
@@ -210,6 +247,10 @@ export function buildApprovalBinding(input: {
|
|
|
210
247
|
workspaceId?: string;
|
|
211
248
|
/** Caller-declared label. Narrows only; never a claim that anything was enforced. */
|
|
212
249
|
contextId?: string;
|
|
250
|
+
/** Optional upstream tree identity. When supplied, later approval use must match it exactly. */
|
|
251
|
+
treeSha?: string;
|
|
252
|
+
/** Optional upstream change floor. When supplied, later approval use must match it exactly. */
|
|
253
|
+
lastChangeSeq?: number;
|
|
213
254
|
}): ApprovalBinding {
|
|
214
255
|
const requested = [...new Set(input.requested)].sort();
|
|
215
256
|
const effective = [...new Set(input.effective)].sort();
|
|
@@ -223,6 +264,8 @@ export function buildApprovalBinding(input: {
|
|
|
223
264
|
...(input.definitionSha256 ? { definition_sha256: input.definitionSha256 } : {}),
|
|
224
265
|
...(input.workspaceId ? { workspace_id: input.workspaceId } : {}),
|
|
225
266
|
...(input.contextId ? { context_id: input.contextId } : {}),
|
|
267
|
+
...(input.treeSha !== undefined ? { tree_sha: input.treeSha } : {}),
|
|
268
|
+
...(input.lastChangeSeq !== undefined ? { last_change_seq: input.lastChangeSeq } : {}),
|
|
226
269
|
parent_id: input.parentId,
|
|
227
270
|
};
|
|
228
271
|
}
|
|
@@ -238,6 +281,10 @@ export function approvalBindingDigest(binding: ApprovalBinding): string {
|
|
|
238
281
|
definition_sha256: binding.definition_sha256 ?? null,
|
|
239
282
|
workspace_id: binding.workspace_id ?? null,
|
|
240
283
|
context_id: binding.context_id ?? null,
|
|
284
|
+
// Keep the exact pre-ADR-0039 serialization when both additions are absent: persisted approvals store
|
|
285
|
+
// this digest, so unconditional null placeholders would invalidate every existing bound approval.
|
|
286
|
+
...(binding.tree_sha !== undefined ? { tree_sha: binding.tree_sha } : {}),
|
|
287
|
+
...(binding.last_change_seq !== undefined ? { last_change_seq: binding.last_change_seq } : {}),
|
|
241
288
|
parent_id: binding.parent_id,
|
|
242
289
|
};
|
|
243
290
|
return createHash("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
|
|
@@ -253,10 +300,12 @@ export function isApprovalBinding(value: unknown): value is ApprovalBinding {
|
|
|
253
300
|
Array.isArray(binding.requested) && binding.requested.every((item) => typeof item === "string") &&
|
|
254
301
|
Array.isArray(binding.effective) && binding.effective.every((item) => typeof item === "string") &&
|
|
255
302
|
typeof binding.parent_id === "string" &&
|
|
256
|
-
["definition_sha256", "workspace_id", "context_id"].every((key) => {
|
|
303
|
+
["definition_sha256", "workspace_id", "context_id", "tree_sha"].every((key) => {
|
|
257
304
|
const value = (binding as Record<string, unknown>)[key];
|
|
258
305
|
return value === undefined || typeof value === "string";
|
|
259
306
|
}) &&
|
|
307
|
+
(binding.last_change_seq === undefined ||
|
|
308
|
+
(typeof binding.last_change_seq === "number" && Number.isFinite(binding.last_change_seq))) &&
|
|
260
309
|
// Self-consistency. This guard's only trust boundary is a binding parsed off DISK
|
|
261
310
|
// (`approval-store.ts`), so an internally contradictory record — digests that do not match the
|
|
262
311
|
// capability arrays sitting beside them — must be unrepresentable rather than merely unlikely.
|
|
@@ -26,8 +26,8 @@ export function resolveDelegationApproval(input: {
|
|
|
26
26
|
spawned?: SkillDefinition;
|
|
27
27
|
definitionDigest?: DefinitionDigest;
|
|
28
28
|
/**
|
|
29
|
-
* Present iff this call is task-bound.
|
|
30
|
-
* the
|
|
29
|
+
* Present iff this call is task-bound. Most values remain labels; optional tree_sha/last_change_seq enter
|
|
30
|
+
* the binding only to narrow a supplied approval to the upstream tree state that was reviewed.
|
|
31
31
|
*/
|
|
32
32
|
correlation?: CorrelationMetadata;
|
|
33
33
|
/** Trusted: an id that was resolved against the operator registry and leased. Never a caller claim. */
|
|
@@ -89,6 +89,8 @@ export function resolveDelegationApproval(input: {
|
|
|
89
89
|
parentId: input.parentId,
|
|
90
90
|
workspaceId: input.boundWorkspaceId,
|
|
91
91
|
contextId: input.boundContextId,
|
|
92
|
+
treeSha: input.correlation?.tree_sha,
|
|
93
|
+
lastChangeSeq: input.correlation?.last_change_seq,
|
|
92
94
|
})
|
|
93
95
|
: undefined;
|
|
94
96
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
interface Finalizer {
|
|
2
|
+
label: string;
|
|
3
|
+
run: () => void | Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const FINALIZER_ERRORS = Symbol("pi-daddy.finalizer-errors");
|
|
7
|
+
|
|
8
|
+
type ErrorWithFinalizers = Error & { [FINALIZER_ERRORS]?: unknown[] };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run every finalizer without allowing one of their failures to replace the operation's primary error.
|
|
12
|
+
*
|
|
13
|
+
* Error identity is preserved when possible: refusal codes and `instanceof` checks are part of callers'
|
|
14
|
+
* control flow. Finalizer failures are attached and added to the primary message instead of wrapping it.
|
|
15
|
+
*/
|
|
16
|
+
export async function runWithFinalizers<T>(operation: () => Promise<T>, finalizers: readonly Finalizer[]): Promise<T> {
|
|
17
|
+
let primaryPresent = false;
|
|
18
|
+
let primary: unknown;
|
|
19
|
+
let result: T | undefined;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
result = await operation();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
primaryPresent = true;
|
|
25
|
+
primary = error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const failures: Array<{ label: string; error: unknown }> = [];
|
|
29
|
+
for (const finalizer of finalizers) {
|
|
30
|
+
try {
|
|
31
|
+
await finalizer.run();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
failures.push({ label: finalizer.label, error });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (primaryPresent) {
|
|
38
|
+
if (failures.length > 0) throw appendFinalizerFailures(primary, failures);
|
|
39
|
+
throw primary;
|
|
40
|
+
}
|
|
41
|
+
if (failures.length === 1) throw failures[0].error;
|
|
42
|
+
if (failures.length > 1) {
|
|
43
|
+
throw new AggregateError(
|
|
44
|
+
failures.map((failure) => failure.error),
|
|
45
|
+
failures.map((failure) => `${failure.label}: ${String(failure.error)}`).join("; "),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return result as T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when a primary error retained a finalizer failure of the requested kind. */
|
|
52
|
+
export function hasFinalizerError(error: unknown, predicate: (value: unknown) => boolean): boolean {
|
|
53
|
+
return error instanceof Error && (error as ErrorWithFinalizers)[FINALIZER_ERRORS]?.some(predicate) === true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function appendFinalizerFailures(primary: unknown, failures: ReadonlyArray<{ label: string; error: unknown }>): unknown {
|
|
57
|
+
if (primary instanceof Error) {
|
|
58
|
+
const target = primary as ErrorWithFinalizers;
|
|
59
|
+
try {
|
|
60
|
+
const prior = target[FINALIZER_ERRORS] ?? [];
|
|
61
|
+
Object.defineProperty(target, FINALIZER_ERRORS, {
|
|
62
|
+
configurable: true,
|
|
63
|
+
value: [...prior, ...failures.map((failure) => failure.error)],
|
|
64
|
+
});
|
|
65
|
+
target.message = [
|
|
66
|
+
target.message,
|
|
67
|
+
...failures.map((failure) => `${failure.label}: ${String(failure.error)}`),
|
|
68
|
+
].join("; ");
|
|
69
|
+
return target;
|
|
70
|
+
} catch {
|
|
71
|
+
// A frozen foreign error cannot be annotated. Keep it as the first AggregateError member and cause.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return new AggregateError(
|
|
75
|
+
[primary, ...failures.map((failure) => failure.error)],
|
|
76
|
+
[String(primary), ...failures.map((failure) => `${failure.label}: ${String(failure.error)}`)].join("; "),
|
|
77
|
+
{ cause: primary },
|
|
78
|
+
);
|
|
79
|
+
}
|
package/src/git-identity.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { runWithFinalizers } from "./finalization.ts";
|
|
6
7
|
import { GovernanceRefusal, refusal } from "./refusals.ts";
|
|
7
8
|
import type { ValidatedWorkspace } from "./workspace.ts";
|
|
8
9
|
|
|
@@ -33,20 +34,23 @@ export async function computeGitCandidateIdentity(workspace: ValidatedWorkspace)
|
|
|
33
34
|
const git = async (args: string[]) => (await execFileAsync("git", ["-C", workspace.root, ...args], {
|
|
34
35
|
env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
|
|
35
36
|
})).stdout.trim();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
37
|
+
return runWithFinalizers(async () => {
|
|
38
|
+
try {
|
|
39
|
+
const headSha = await git(["rev-parse", "HEAD"]);
|
|
40
|
+
await git(["read-tree", "HEAD"]);
|
|
41
|
+
await git(["add", "-A"]);
|
|
42
|
+
const treeSha = await git(["write-tree"]);
|
|
43
|
+
if (!/^[a-f0-9]{40,64}$/i.test(headSha) || !/^[a-f0-9]{40,64}$/i.test(treeSha)) throw new Error("Git returned an invalid object id");
|
|
44
|
+
return { headSha, treeSha };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new GovernanceRefusal(refusal(
|
|
47
|
+
"CHECK_IDENTITY_UNAVAILABLE",
|
|
48
|
+
`could not compute exact Git head/candidate-tree identity for workspace ${workspace.workspaceId} (${String(error)})`,
|
|
49
|
+
{ workspace_id: workspace.workspaceId },
|
|
50
|
+
));
|
|
51
|
+
}
|
|
52
|
+
}, [{
|
|
53
|
+
label: "temporary Git index cleanup failed",
|
|
54
|
+
run: () => rm(dir, { recursive: true, force: true }),
|
|
55
|
+
}]);
|
|
52
56
|
}
|