pi-daddy 0.23.0 → 0.24.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 +10 -0
- package/README.md +12 -6
- package/contracts/ledger/v4/README.md +1 -1
- package/dist/cli.d.ts +3 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +49 -3
- package/dist/cli.js.map +1 -1
- package/dist/daily-dashboard-host.d.ts +99 -0
- package/dist/daily-dashboard-host.d.ts.map +1 -0
- package/dist/daily-dashboard-host.js +71 -0
- package/dist/daily-dashboard-host.js.map +1 -0
- package/dist/dashboard-cli.d.ts.map +1 -1
- package/dist/dashboard-cli.js +9 -2
- package/dist/dashboard-cli.js.map +1 -1
- package/dist/dashboard-harness.d.ts +3 -0
- package/dist/dashboard-harness.d.ts.map +1 -1
- package/dist/dashboard-harness.js +14 -0
- package/dist/dashboard-harness.js.map +1 -1
- package/dist/dashboard-host-contract.d.ts +6 -1
- package/dist/dashboard-host-contract.d.ts.map +1 -1
- package/dist/dashboard-host-contract.js +1 -1
- package/dist/dashboard-host-contract.js.map +1 -1
- package/dist/dashboard-host-transport.d.ts +1 -0
- package/dist/dashboard-host-transport.d.ts.map +1 -1
- package/dist/dashboard-host-transport.js +5 -3
- package/dist/dashboard-host-transport.js.map +1 -1
- package/dist/dashboard-host.d.ts +54 -0
- package/dist/dashboard-host.d.ts.map +1 -1
- package/dist/dashboard-host.js +49 -2
- package/dist/dashboard-host.js.map +1 -1
- package/dist/dashboard-observation.d.ts.map +1 -1
- package/dist/dashboard-observation.js +5 -5
- package/dist/dashboard-observation.js.map +1 -1
- package/dist/experiment-store.d.ts +3 -1
- package/dist/experiment-store.d.ts.map +1 -1
- package/dist/experiment-store.js +20 -3
- package/dist/experiment-store.js.map +1 -1
- package/dist/work-command.d.ts +30 -0
- package/dist/work-command.d.ts.map +1 -0
- package/dist/work-command.js +148 -0
- package/dist/work-command.js.map +1 -0
- package/extensions/daily-dashboard-session.ts +30 -0
- package/extensions/daily-work-session.ts +20 -0
- package/extensions/delegation.ts +21 -8
- package/extensions/execute-child.ts +10 -1
- package/extensions/executor-session.ts +9 -0
- package/extensions/grants-command.ts +14 -15
- package/extensions/grants-connected-command.ts +19 -0
- package/extensions/grants.ts +25 -3
- package/extensions/primary-shadow.ts +11 -0
- package/extensions/run-delegation.ts +2 -0
- package/extensions/session.ts +7 -19
- package/extensions/work-runtime.ts +44 -0
- package/package.json +1 -1
- package/src/cli.ts +41 -4
- package/src/daily-dashboard-host.ts +49 -0
- package/src/dashboard-cli.ts +7 -2
- package/src/dashboard-harness.ts +9 -0
- package/src/dashboard-host-contract.ts +4 -1
- package/src/dashboard-host-transport.ts +8 -6
- package/src/dashboard-host.ts +26 -3
- package/src/dashboard-observation.ts +3 -2
- package/src/experiment-store.ts +11 -3
- package/src/work-command.ts +179 -0
package/extensions/grants.ts
CHANGED
|
@@ -37,7 +37,8 @@ import { registerDelegationTools } from "./delegation.ts";
|
|
|
37
37
|
import { grantsCommand } from "./grants-command.ts";
|
|
38
38
|
import { runInit } from "./init-command.ts";
|
|
39
39
|
import { planWithApprovals } from "./run-delegation.ts";
|
|
40
|
-
import { createGrantsSession, loadProjectDefinitions,
|
|
40
|
+
import { createGrantsSession, loadProjectDefinitions, type GrantsSession } from "./session.ts";
|
|
41
|
+
import { resolveExecutor } from "./executor-session.ts";
|
|
41
42
|
import { reportSessionStart } from "./session-report.ts";
|
|
42
43
|
import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
|
|
43
44
|
import { reportGrantStoreRefusal } from "./grant-store-refusal.ts";
|
|
@@ -47,7 +48,10 @@ import {
|
|
|
47
48
|
openDashboardCommand,
|
|
48
49
|
} from "../src/dashboard-handshake.ts";
|
|
49
50
|
|
|
50
|
-
import { associateOrdinaryHost } from "../src/ordinary-children.ts";
|
|
51
|
+
import { associateOrdinaryHost, ordinaryChildrenFor } from "../src/ordinary-children.ts";
|
|
52
|
+
import { loadDeclaredWork } from "../src/work-command.ts";
|
|
53
|
+
import { createDailyDashboardSession } from "./daily-dashboard-session.ts";
|
|
54
|
+
import { replacePublishedDailyWork, type PublishedDailyWork } from "./daily-work-session.ts";
|
|
51
55
|
export default function (pi: ExtensionAPI) {
|
|
52
56
|
// The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
|
|
53
57
|
// Only this file can say so about itself, which is why the session takes it rather than deriving it.
|
|
@@ -61,6 +65,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
61
65
|
|
|
62
66
|
const session = createGrantsSession(extensionPath);
|
|
63
67
|
associateOrdinaryHost(pi,session);
|
|
68
|
+
const dailyHost=createDailyDashboardSession({ordinary:()=>ordinaryChildrenFor(pi),declared:()=>session.declaredWork,cwd:()=>session.cwd,env:process.env,author:"local-operator"});
|
|
64
69
|
const dashboardPluginRoot = fileURLToPath(new URL("../herdr-plugin/", import.meta.url));
|
|
65
70
|
const dashboardPaths = defaultDashboardPaths(
|
|
66
71
|
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"),
|
|
@@ -70,6 +75,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
75
|
// because the hooks below have to be registered before the tools and both need to call it — the tool
|
|
71
76
|
// schemas describe which definitions are spawnable, and nothing knows that until a hook has run (R-39).
|
|
72
77
|
const delegation = { refreshSpawnable: () => {} };
|
|
78
|
+
const publishedDailyWork: PublishedDailyWork = {};
|
|
73
79
|
|
|
74
80
|
pi.on("session_start", async (_event, ctx) => {
|
|
75
81
|
session.cwd = ctx.cwd;
|
|
@@ -77,6 +83,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
77
83
|
// execution tree across workspaces. Resolve it once at the root's actual pi cwd; descendants inherit
|
|
78
84
|
// this absolute identity verbatim, and resolve(abs) remains abs at every deeper session start.
|
|
79
85
|
if (session.ledgerPath) session.ledgerPath = resolve(ctx.cwd, session.ledgerPath);
|
|
86
|
+
try {
|
|
87
|
+
try {
|
|
88
|
+
replacePublishedDailyWork(process.env, publishedDailyWork, undefined);
|
|
89
|
+
session.declaredWork = (await loadDeclaredWork(join(ctx.cwd, ".pi", "work-current.json"))) ?? undefined;
|
|
90
|
+
replacePublishedDailyWork(process.env, publishedDailyWork, session.declaredWork);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
session.declaredWork = undefined;
|
|
93
|
+
ctx.ui.notify(
|
|
94
|
+
`grants: declared work is invalid (${error instanceof Error ? error.message : String(error)}); ` +
|
|
95
|
+
`delegations remain unbound and no previous selection was substituted.`,
|
|
96
|
+
"error",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
80
99
|
// The one case the stored-grant lookup can get wrong (ADR-0030). The factory reads the store keyed by
|
|
81
100
|
// `process.cwd()` because it runs before any hook and therefore before `ctx` exists — and S-5 forces
|
|
82
101
|
// that ordering, since whether `delegate` is registered is decided there. Almost always the two agree.
|
|
@@ -95,7 +114,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
114
|
"warning",
|
|
96
115
|
);
|
|
97
116
|
}
|
|
98
|
-
try {
|
|
99
117
|
try {
|
|
100
118
|
await reportGrantStoreRefusal(session, ctx.ui);
|
|
101
119
|
} catch (error) {
|
|
@@ -232,6 +250,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
232
250
|
* `exit` remains the backstop. SIGKILL still orphans panes, exactly as R-62 records, and no signal handler is
|
|
233
251
|
* installed here for the reason R-62 gives: it would turn pi's "interrupt this turn" into "exit pi".
|
|
234
252
|
*/
|
|
253
|
+
pi.on("session_shutdown", async () => { await dailyHost.close().catch(() => undefined); });
|
|
254
|
+
|
|
235
255
|
pi.on("agent_settled", async () => {
|
|
236
256
|
try {
|
|
237
257
|
if (session.executor.kind !== "herdr" || openPaneCount() === 0) return undefined;
|
|
@@ -350,6 +370,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
350
370
|
definitions: session.definitions,
|
|
351
371
|
sessionApprovals: session.sessionApprovals,
|
|
352
372
|
inheritedApprovals: session.inheritedApprovals,
|
|
373
|
+
variantRuns: session.variantRuns,
|
|
353
374
|
snapshotOf: (subject: string) => snapshotOf(session, subject),
|
|
354
375
|
// The REAL delegation path, minus the one thing a diagnostic must never do. `ctx: null` is what
|
|
355
376
|
// says so: stored approvals count exactly as they would for a spawn, and no human is asked
|
|
@@ -360,6 +381,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
360
381
|
await loadProjectDefinitions(session, ctx.cwd);
|
|
361
382
|
delegation.refreshSpawnable();
|
|
362
383
|
}),
|
|
384
|
+
runHost: (target: string) => dailyHost.run(target),
|
|
363
385
|
openDashboard: () => openDashboardCommand({
|
|
364
386
|
env: process.env,
|
|
365
387
|
pid: process.pid,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DelegationOutcome } from "./execute-child.ts";
|
|
2
|
+
import type { ExecutionOccurrenceIds } from "./execution-occurrence.ts";
|
|
3
|
+
import type { GrantsSession, VariantRunAccounting } from "./session.ts";
|
|
4
|
+
|
|
5
|
+
/** Return one primary while the original session retains and settles every shadow outcome. */
|
|
6
|
+
export async function completePrimary(input:{session:GrantsSession;primaryIndex:number;occurrences:ExecutionOccurrenceIds[];pending:Promise<DelegationOutcome>[];settle:(outcomes:{ok:boolean}[])=>void}){
|
|
7
|
+
const runId=input.occurrences[input.primaryIndex].executionId,record:VariantRunAccounting={runId,primaryExecutionId:runId,shadowExecutionIds:input.occurrences.filter((_,i)=>i!==input.primaryIndex).map(x=>x.executionId),state:"running",outcomes:null};
|
|
8
|
+
input.session.variantRuns.set(runId,record);
|
|
9
|
+
void Promise.all(input.pending).then(outcomes=>{record.outcomes=outcomes.map((outcome,index)=>({executionId:input.occurrences[index].executionId,role:index===input.primaryIndex?"primary":"shadow",ok:outcome.ok,reason:outcome.ok?null:outcome.reason??"failed"}));record.state="settled";input.settle(outcomes);});
|
|
10
|
+
return {runId,primary:await input.pending[input.primaryIndex]};
|
|
11
|
+
}
|
|
@@ -44,6 +44,7 @@ interface ChildSpec {
|
|
|
44
44
|
agent?: string;
|
|
45
45
|
tools?: string[];
|
|
46
46
|
model?: string;
|
|
47
|
+
thinking?: string;
|
|
47
48
|
correlation?: CorrelationMetadata;
|
|
48
49
|
workspace?: DelegationWorkspaceSpec;
|
|
49
50
|
}
|
|
@@ -248,6 +249,7 @@ export async function runOneDelegation(
|
|
|
248
249
|
agent: spec.agent,
|
|
249
250
|
tools: spec.tools,
|
|
250
251
|
model: spec.model ?? defaultModel,
|
|
252
|
+
thinking: spec.thinking,
|
|
251
253
|
correlation: spec.workspace
|
|
252
254
|
? { ...(spec.correlation ?? {}), workspace_id: spec.workspace.workspace_id }
|
|
253
255
|
: spec.correlation,
|
package/extensions/session.ts
CHANGED
|
@@ -21,8 +21,7 @@ import { makeCatalog, skillPathsFromCatalog, type Catalog } from "../src/catalog
|
|
|
21
21
|
import type { SkillDefinition } from "../src/definitions.ts";
|
|
22
22
|
import { DELEGATE_CAPABILITY, type DelegationContext } from "../src/delegate.ts";
|
|
23
23
|
import { budgetFromEnv } from "../src/fanout.ts";
|
|
24
|
-
import { chooseExecutor,
|
|
25
|
-
import { probeHerdr } from "../src/herdr-cli.ts";
|
|
24
|
+
import { chooseExecutor, ENV_HERDR, type ExecutorChoice } from "../src/executor.ts";
|
|
26
25
|
import { WILDCARD } from "../src/pi-tools.ts";
|
|
27
26
|
import {
|
|
28
27
|
childEnv,
|
|
@@ -49,7 +48,7 @@ import { republishable } from "./approvals.ts";
|
|
|
49
48
|
import { storedGrantSessionState } from "./stored-grant-session.ts";
|
|
50
49
|
import { nativeSessionRootFromEnv, type NativeSessionHost } from "../src/native-session-target.ts";
|
|
51
50
|
import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/model-preflight.ts";
|
|
52
|
-
|
|
51
|
+
import type { DeclaredWorkState } from "../src/work-command.ts";
|
|
53
52
|
/**
|
|
54
53
|
* Run governed children in herdr panes instead of captured child processes.
|
|
55
54
|
*
|
|
@@ -80,6 +79,7 @@ export { ENV_HERDR_WORKSPACE } from "../src/herdr-cli.ts";
|
|
|
80
79
|
/** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */
|
|
81
80
|
export const ENV_HERDR_KEEP_PANE = "PI_GRANTS_HERDR_KEEP_PANE";
|
|
82
81
|
|
|
82
|
+
export interface VariantRunAccounting {runId:string;primaryExecutionId:string;shadowExecutionIds:string[];state:"running"|"settled";outcomes:null|{executionId:string;role:"primary"|"shadow";ok:boolean;reason:string|null}[]}
|
|
83
83
|
export interface GrantsSession extends NativeSessionHost {
|
|
84
84
|
/**
|
|
85
85
|
* False when neither `PI_GRANTS_GRANT` nor a stored grant applies: the session holds the wildcard and
|
|
@@ -127,6 +127,9 @@ export interface GrantsSession extends NativeSessionHost {
|
|
|
127
127
|
readonly modelResolutionCache: Map<string, boolean>;
|
|
128
128
|
/** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
|
|
129
129
|
readonly extensionPath?: string;
|
|
130
|
+
declaredWork?: DeclaredWorkState; // Explicit operator selection; absence leaves execution visibly unbound.
|
|
131
|
+
/** Primary-return fan-outs retained by this original session; bounded and human-readable via /grants variants. */
|
|
132
|
+
readonly variantRuns: Map<string, VariantRunAccounting>;
|
|
130
133
|
|
|
131
134
|
/** Approval keys approved for this session. In memory only — this dies with the process. */
|
|
132
135
|
readonly sessionApprovals: Set<string>;
|
|
@@ -235,22 +238,6 @@ export async function loadProjectDefinitions(session: GrantsSession, cwd: string
|
|
|
235
238
|
session.catalog = await session.catalogReady;
|
|
236
239
|
}
|
|
237
240
|
|
|
238
|
-
/**
|
|
239
|
-
* Probe for herdr and settle this session's executor — ADR-0031.
|
|
240
|
-
*
|
|
241
|
-
* **Once, at session start, and never per spawn.** A fan-out whose children ran under two executors would put
|
|
242
|
-
* two different things under one call in the ledger, and the two plans differ (`--print` is withheld on the
|
|
243
|
-
* herdr path). A herdr server that dies mid-session therefore surfaces as a failed `tab create`, reported as
|
|
244
|
-
* the spawn error it is, rather than as a silent relocation of the remaining children.
|
|
245
|
-
*
|
|
246
|
-
* `probeHerdr` never throws, so this cannot either — which matters because it runs *before* the line that
|
|
247
|
-
* discloses what it decided (R-60: a throw here would cancel that line and every control after it).
|
|
248
|
-
*/
|
|
249
|
-
export async function resolveExecutor(session: GrantsSession): Promise<void> {
|
|
250
|
-
const raw = process.env[ENV_HERDR];
|
|
251
|
-
session.executor = chooseExecutor(raw, needsProbe(raw) ? await probeHerdr() : null);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
241
|
export function createGrantsSession(extensionPath: string | undefined): GrantsSession {
|
|
255
242
|
// Governance is opt-in: with PI_GRANTS_GRANT unset AND no stored grant for this directory, the session
|
|
256
243
|
// holds the wildcard and nothing is blocked. This extension must never silently tighten a normal
|
|
@@ -322,6 +309,7 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
|
|
|
322
309
|
nativeSessionRoot: nativeSessionRootFromEnv(process.env),
|
|
323
310
|
modelResolutionCache: new Map<string, boolean>(),
|
|
324
311
|
extensionPath,
|
|
312
|
+
variantRuns: new Map(),
|
|
325
313
|
|
|
326
314
|
sessionApprovals: new Set<string>(),
|
|
327
315
|
sessionApprovalBindings: new Map<string, ApprovalBinding>(),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Delegation } from "../src/delegate.ts";
|
|
2
|
+
import { retentionConfigurationDigest } from "../src/execution-retention.ts";
|
|
3
|
+
import { appendDeclaredWorkOccurrence } from "../src/work-command.ts";
|
|
4
|
+
import type { WorkOccurrencePayload } from "../src/work-ledger.ts";
|
|
5
|
+
import type { GrantsSession } from "./session.ts";
|
|
6
|
+
import type { PreparedWorkspace } from "./workspace-runtime.ts";
|
|
7
|
+
|
|
8
|
+
export async function beginDeclaredWorkAttempt(input: {
|
|
9
|
+
session: GrantsSession;
|
|
10
|
+
plan: Delegation;
|
|
11
|
+
childId: string;
|
|
12
|
+
executionId: string;
|
|
13
|
+
parentExecutionId: string | null;
|
|
14
|
+
toolCallId?: string;
|
|
15
|
+
preparedWorkspace?: PreparedWorkspace;
|
|
16
|
+
configuredTimeoutMs: number;
|
|
17
|
+
startedAt: Date;
|
|
18
|
+
}): Promise<null | { finish(state: Extract<WorkOccurrencePayload["state"], "completed" | "failed">): Promise<void> }> {
|
|
19
|
+
const state = input.session.declaredWork;
|
|
20
|
+
if (!state) return null;
|
|
21
|
+
const argument = (flag: string): string | null => {
|
|
22
|
+
const index = input.plan.args.indexOf(flag);
|
|
23
|
+
return index >= 0 ? input.plan.args[index + 1] ?? null : null;
|
|
24
|
+
};
|
|
25
|
+
const identity = {
|
|
26
|
+
executionId: input.executionId,
|
|
27
|
+
parentExecutionId: input.parentExecutionId,
|
|
28
|
+
childId: input.childId,
|
|
29
|
+
variantId: input.plan.correlation?.context_id ?? null,
|
|
30
|
+
toolCallId: input.toolCallId ?? null,
|
|
31
|
+
taskId: input.plan.correlation?.task_id ?? input.plan.taskDigest,
|
|
32
|
+
workspaceId: input.preparedWorkspace?.workspace.workspaceId ?? input.plan.correlation?.workspace_id ?? null,
|
|
33
|
+
definitionDigest: input.plan.definitionDigest?.sha256 ?? null,
|
|
34
|
+
configurationDigest: retentionConfigurationDigest({
|
|
35
|
+
args: input.plan.args,
|
|
36
|
+
effective: input.plan.effective,
|
|
37
|
+
timeoutMs: input.configuredTimeoutMs,
|
|
38
|
+
}),
|
|
39
|
+
modelId: argument("--model"),
|
|
40
|
+
effortId: argument("--thinking"),
|
|
41
|
+
};
|
|
42
|
+
await appendDeclaredWorkOccurrence(state, { ...identity, now: input.startedAt }, "starting");
|
|
43
|
+
return { finish: (terminal) => appendDeclaredWorkOccurrence(state, { ...identity, now: new Date() }, terminal) };
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|
package/src/cli.ts
CHANGED
|
@@ -21,12 +21,15 @@ import { UnsafeGrantError } from "./grant-env.ts";
|
|
|
21
21
|
import { applyInit, countDeclaring, planInit, type InitPlan } from "./init.ts";
|
|
22
22
|
import { registeredWorkspaceIds } from "./workspace.ts";
|
|
23
23
|
import { discoverSkillPackages, skillPackageRoots, type RefusedSkill, type SkillPackage } from "./skill-packages.ts";
|
|
24
|
+
import { declareWork } from "./work-command.ts";
|
|
24
25
|
|
|
25
26
|
const USAGE = `pi-daddy — capability governance for pi sub-agents
|
|
26
27
|
|
|
27
28
|
Usage:
|
|
28
29
|
pi-daddy init [--force] [--dir <path>] scaffold .pi/skills/ and .pi/grants.env from installed
|
|
29
30
|
packages that declare skills (package.json "pi": {"skills": …})
|
|
31
|
+
pi-daddy work add --id <id> --outcome <text> [--dir <path>]
|
|
32
|
+
declare one current obligation for ordinary delegation
|
|
30
33
|
pi-daddy --help | --version
|
|
31
34
|
|
|
32
35
|
init copies each declared SKILL.md into .pi/skills/ and writes a grant naming exactly what those files
|
|
@@ -38,9 +41,11 @@ placeholder and stays unspawnable until you fill it in. Capabilities that can ch
|
|
|
38
41
|
It never rewrites .pi/grants.env — delete that file if you want it regenerated.`;
|
|
39
42
|
|
|
40
43
|
export interface ParsedArgs {
|
|
41
|
-
command: "init" | "help" | "version";
|
|
44
|
+
command: "init" | "work-add" | "help" | "version";
|
|
42
45
|
dir?: string;
|
|
43
46
|
force: boolean;
|
|
47
|
+
id?: string;
|
|
48
|
+
outcome?: string;
|
|
44
49
|
/** Non-empty means refuse: argv said something this program does not understand. */
|
|
45
50
|
errors: string[];
|
|
46
51
|
}
|
|
@@ -58,16 +63,28 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
58
63
|
if (args.includes("--help") || args.includes("-h")) return { command: "help", force: false, errors: [] };
|
|
59
64
|
if (args.includes("--version") || args.includes("-v")) return { command: "version", force: false, errors: [] };
|
|
60
65
|
|
|
61
|
-
const [command, ...
|
|
62
|
-
if (command !== "init") return { command: "help", force: false, errors: [`unknown command "${command}"`] };
|
|
66
|
+
const [command, ...tail] = args;
|
|
67
|
+
if (command !== "init" && command !== "work") return { command: "help", force: false, errors: [`unknown command "${command}"`] };
|
|
68
|
+
const work = command === "work";
|
|
69
|
+
const rest = work && tail[0] === "add" ? tail.slice(1) : tail;
|
|
70
|
+
if (work && tail[0] !== "add") return { command: "work-add", force: false, errors: ["work needs subcommand add"] };
|
|
63
71
|
|
|
64
72
|
const errors: string[] = [];
|
|
65
73
|
let dir: string | undefined;
|
|
66
74
|
let force = false;
|
|
75
|
+
let id: string | undefined, outcome: string | undefined;
|
|
67
76
|
for (let i = 0; i < rest.length; i += 1) {
|
|
68
77
|
const arg = rest[i];
|
|
69
|
-
if (arg === "--force") {
|
|
78
|
+
if (arg === "--force" && !work) {
|
|
70
79
|
force = true;
|
|
80
|
+
} else if (arg === "--id" && work) {
|
|
81
|
+
const value = rest[i + 1];
|
|
82
|
+
if (value === undefined || value.startsWith("-")) errors.push("--id needs a value");
|
|
83
|
+
else { id = value; i += 1; }
|
|
84
|
+
} else if (arg === "--outcome" && work) {
|
|
85
|
+
const value = rest[i + 1];
|
|
86
|
+
if (value === undefined || value.startsWith("-")) errors.push("--outcome needs text");
|
|
87
|
+
else { outcome = value; i += 1; }
|
|
71
88
|
} else if (arg === "--dir") {
|
|
72
89
|
const value = rest[i + 1];
|
|
73
90
|
// A flag is not a path. Without this, `--dir --force` consumed the flag as the directory AND left
|
|
@@ -87,6 +104,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
87
104
|
}
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
if (work) {
|
|
108
|
+
if (!id && !errors.some(error => error.startsWith("--id"))) errors.push("--id needs a value");
|
|
109
|
+
if (!outcome && !errors.some(error => error.startsWith("--outcome"))) errors.push("--outcome needs text");
|
|
110
|
+
return { command: "work-add", force: false, errors, ...(id ? { id } : {}), ...(outcome ? { outcome } : {}), ...(dir ? { dir } : {}) };
|
|
111
|
+
}
|
|
90
112
|
return { command: "init", dir, force, errors };
|
|
91
113
|
}
|
|
92
114
|
|
|
@@ -260,6 +282,21 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
260
282
|
console.log(USAGE);
|
|
261
283
|
return 0;
|
|
262
284
|
}
|
|
285
|
+
if (parsed.command === "work-add") {
|
|
286
|
+
const declared = await declareWork({
|
|
287
|
+
cwd: resolvePath(parsed.dir ?? process.cwd()),
|
|
288
|
+
id: parsed.id!,
|
|
289
|
+
outcome: parsed.outcome!,
|
|
290
|
+
});
|
|
291
|
+
console.log(
|
|
292
|
+
`pi-daddy work: declared ${declared.id}\n` +
|
|
293
|
+
` ledger ${declared.ledgerPath}\n` +
|
|
294
|
+
` selection ${declared.statePath}\n\n` +
|
|
295
|
+
`Ordinary governed delegations from this project will record attempts after the Pi extension loads it.\n` +
|
|
296
|
+
`Run /reload in an existing Pi session, then /grants dashboard. Runtime success is not acceptance.`,
|
|
297
|
+
);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
263
300
|
|
|
264
301
|
// ABSOLUTE, always. `readSkill` compares a resolved entry against the package directory, and a relative
|
|
265
302
|
// `--dir` made that comparison false for every entry: every declared skill of every package was reported
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, resolve, join } from "node:path";
|
|
4
|
+
import { createDashboardHost, dashboardHostDigest, dashboardHostRequestDigest, type DashboardHostAuthority, type DashboardHostConfig, type DashboardHostRequest } from "./dashboard-host.ts";
|
|
5
|
+
import type { DashboardHarness } from "./dashboard-host-contract.ts";
|
|
6
|
+
import { loadedDashboardHarnessDigest } from "./dashboard-harness.ts";
|
|
7
|
+
import { createDispatchBudget, openResourceBudget, resourceBindingDigest } from "./resource-budget.ts";
|
|
8
|
+
import { dispatchRequestDigest, type DispatchRequest } from "./dispatch-control.ts";
|
|
9
|
+
import { serveDashboardHost } from "./dashboard-host-transport.ts";
|
|
10
|
+
import type { DeclaredWorkState } from "./work-command.ts";
|
|
11
|
+
import type { OrdinaryChildren } from "./ordinary-children.ts";
|
|
12
|
+
import { dataDigest } from "./debrief-contract.ts";
|
|
13
|
+
|
|
14
|
+
export interface DailyDashboardHostInput {id:string;cwd:string;directory:string;socketPath?:string;declared:DeclaredWorkState;ordinary:OrdinaryChildren;harness:DashboardHarness;author:string}
|
|
15
|
+
const idPattern=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
16
|
+
const hash=(value:string)=>createHash("sha256").update(value).digest("hex");
|
|
17
|
+
|
|
18
|
+
/** One explicitly started, process-owned daily host. No authority, controller or prior host is recovered from disk. */
|
|
19
|
+
export async function startDailyDashboardHost(input:DailyDashboardHostInput){
|
|
20
|
+
if(!idPattern.test(input.id)||!isAbsolute(input.cwd)||resolve(input.cwd)!==input.cwd||!isAbsolute(input.directory)||resolve(input.directory)!==input.directory||!input.author.trim()||input.author.length>256)throw Error("explicit bounded daily host configuration required");
|
|
21
|
+
const harnessDigest=loadedDashboardHarnessDigest(input.harness);if(!harnessDigest)throw Error("loaded skill-harness bridge required");
|
|
22
|
+
if(input.declared.ledgerPath!==join(input.cwd,".pi","work.jsonl"))throw Error("current declared work must belong to this host project");
|
|
23
|
+
await mkdir(input.directory,{mode:0o700});const archiveRoot=join(input.directory,"archive");await mkdir(archiveRoot,{mode:0o700});
|
|
24
|
+
const reference=input.harness.retainArchiveSource(archiveRoot,{sourceId:`host-${input.id}-bootstrap`,parser:{id:"daily-host-bootstrap",version:"1"},retention:"exact",bytes:Buffer.from("explicit daily host bootstrap")});
|
|
25
|
+
const component={kind:"detector",id:"retained-work",version:"1",population:`daily:${input.id}`};
|
|
26
|
+
const trustInput={archiveRoot,component,seed:hash(`daily-host\0${input.id}`),maxUnflagged:0,cohort:[{incidentId:"bootstrap",manifestId:reference.manifestId,flagged:true,split:"calibration"}],exposure:null};
|
|
27
|
+
const trustPolicyId=input.harness.trustPolicyDigest(trustInput),trustDirectory=join(input.directory,"trust");input.harness.createTrustLifecycle(trustDirectory,trustInput,[trustPolicyId]);
|
|
28
|
+
const workRelative="work.jsonl",factsRelative="facts.jsonl",workMirror=join(input.directory,workRelative);await writeFile(workMirror,await readFile(input.declared.ledgerPath),{mode:0o600,flag:"wx"});
|
|
29
|
+
const refreshWork=async()=>{const temporary=`${workMirror}.${process.pid}.${randomUUID()}.tmp`;try{await writeFile(temporary,await readFile(input.declared.ledgerPath),{mode:0o600,flag:"wx"});await rename(temporary,workMirror);}finally{await rm(temporary,{force:true});}};
|
|
30
|
+
const facts={scopeDigest:input.declared.selectedSnapshot.snapshot.digest,version:"declared-work-v1",population:`daily:${input.id}`,expectedWaits:[],checkpoints:[],violations:[],priorAccepted:[]};await writeFile(join(input.directory,factsRelative),JSON.stringify(facts)+"\n",{mode:0o600,flag:"wx"});
|
|
31
|
+
const policy={version:"archive-policy-v2",id:`daily-${input.id}`,revision:"1",sourceRoot:input.directory,archiveRoot,maxBytes:8*1024*1024,retention:"exact",expiresAt:new Date(Date.now()+24*60*60*1000).toISOString(),sources:[{id:"work",path:workRelative,parser:{id:"pi-daddy-work-ledger",version:"4"},contentPolicy:"manifest-only"},{id:"facts",path:factsRelative,parser:{id:"pi-daddy-work-signal-facts",version:"1"},contentPolicy:"manifest-only"}]};
|
|
32
|
+
const policyPath=join(input.directory,"archive-policy.json"),policyBytes=JSON.stringify(policy);await writeFile(policyPath,policyBytes,{mode:0o600,flag:"wx"});
|
|
33
|
+
const authorityDigest=dataDigest({kind:"daily-dashboard-host",id:input.id,cwd:input.cwd}),budget=await createDispatchBudget({directory:join(input.directory,"budget"),authorityDigest,limits:{maxAttempts:6,maxInputBytes:65536,maxConcurrent:3}});
|
|
34
|
+
const config:DashboardHostConfig={version:"producer-dashboard-host-v1",trustDirectory,trustPolicyId,archiveRoot,scope:`daily:${input.id}`,author:input.author,policyPath,policySha256:hash(policyBytes),sources:[{id:"work",kind:"work"},{id:"facts",kind:"facts"}],selection:input.declared.selectedSnapshot,cases:null,blind:null,budgetDigest:resourceBindingDigest(budget),experimentDigest:null,harnessArtifactDigest:harnessDigest,ordinaryDigest:input.ordinary.bindingDigest};
|
|
35
|
+
const requestDigests=new Set<string>(),dispatchDigests=new Set<string>();let host:ReturnType<typeof createDashboardHost>,factsResultManifest:string|null=null;
|
|
36
|
+
const authority=():DashboardHostAuthority=>({hostDigests:[dashboardHostDigest(config)],requestDigests:[...requestDigests],workContext:{selectedSnapshot:config.selection,authority:null},dispatch:{authorityDigest,requestDigests:[...dispatchDigests]},experiment:null});
|
|
37
|
+
const request=(context:{hostDigest:string;selectionDigest:string;tip:string},operation:DashboardHostRequest["operation"],payload:unknown,requestId:string):DashboardHostRequest=>({version:"1.0",requestId,hostDigest:context.hostDigest,selectionDigest:context.selectionDigest,expectedTip:context.tip,operation,payload});
|
|
38
|
+
host=createDashboardHost({harness:input.harness,config,budget,ordinary:input.ordinary,authority,beforeObservation:sourceId=>sourceId==="work"?refreshWork():Promise.resolve(),humanActions:async context=>{
|
|
39
|
+
const state=await openResourceBudget(budget).controls(null).inspect(),action=state.paused?"resume-dispatch":"pause-dispatch",key=state.paused?"resume-dispatch":"pause-new-dispatch";
|
|
40
|
+
const native:DispatchRequest={version:"1.0",requestId:`${action}:${state.revision}`,bindingDigest:config.budgetDigest,expectedRevision:state.revision,action,targetExecutionId:null};dispatchDigests.add(dispatchRequestDigest(native));
|
|
41
|
+
const hostRequest=request(context,"dispatch",native,`host-${action}:${state.revision}`);requestDigests.add(dashboardHostRequestDigest(hostRequest));
|
|
42
|
+
const previous=context.observations.find(x=>x.sourceId==="work")?.checkpointId??null,refresh=request(context,"observe",{sourceId:"work",previousCheckpointId:previous,facts:context.observations.find(x=>x.sourceId==="facts")?.checkpointId?factsResultManifest:null},`refresh-work:${state.revision}:${context.tip.slice(0,12)}`);requestDigests.add(dashboardHostRequestDigest(refresh));
|
|
43
|
+
return [{key,label:state.paused?"Resume new governed dispatch":"Pause new governed dispatch; running children continue",request:hostRequest},{key:"refresh-current-work",label:"Capture current declared work and attempts",request:refresh}];
|
|
44
|
+
}});
|
|
45
|
+
const before=await host.frame(),observeFacts=request({hostDigest:host.hostDigest,selectionDigest:before.selectionDigest,tip:before.tip},"observe",{sourceId:"facts",previousCheckpointId:null,facts:null},"observe-current-facts");requestDigests.add(dashboardHostRequestDigest(observeFacts));const factsResult=await host.action(observeFacts) as {result:{sourceManifestId:string}};factsResultManifest=factsResult.result.sourceManifestId;
|
|
46
|
+
const afterFacts=await host.frame(),observeWork=request({hostDigest:host.hostDigest,selectionDigest:afterFacts.selectionDigest,tip:afterFacts.tip},"observe",{sourceId:"work",previousCheckpointId:null,facts:factsResult.result.sourceManifestId},"observe-current-work");requestDigests.add(dashboardHostRequestDigest(observeWork));await host.action(observeWork);
|
|
47
|
+
const socketPath=input.socketPath??join(input.directory,"dashboard.sock"),server=await serveDashboardHost(socketPath,host);
|
|
48
|
+
let closed=false;return Object.freeze({host,socketPath,directory:input.directory,async close(){if(closed)return;closed=true;await server.close();}});
|
|
49
|
+
}
|
package/src/dashboard-cli.ts
CHANGED
|
@@ -52,7 +52,10 @@ export interface DashboardFrameOptions {
|
|
|
52
52
|
}
|
|
53
53
|
export async function dashboardHostAction(host:DashboardConnection,line:string){
|
|
54
54
|
if(!isDashboardConnection(host)||Buffer.byteLength(line)>60000)throw Error("genuine bounded dashboard connection required");
|
|
55
|
-
|
|
55
|
+
const command=line.trim();
|
|
56
|
+
if(command.startsWith("{"))return host.action(dashboardHostRequest(parseRetentionJson(command,60000) as DashboardHostRequest));
|
|
57
|
+
if(!/^[a-zA-Z0-9:_-]{1,64}$/.test(command))throw Error("dashboard action must be a listed command key");
|
|
58
|
+
return host.humanAction(command);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
function shellQuote(value: string): string {
|
|
@@ -96,9 +99,11 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
|
|
|
96
99
|
try{view=await options.connected.frame();}catch{return "PI-DADDY — CONNECTED HOST UNAVAILABLE\nNo prior acceptance, presentation or continuity substituted. No action retried.";}
|
|
97
100
|
if(options.dailyJson||options.debriefJson)return JSON.stringify(view);
|
|
98
101
|
const source=view.source as {daily?:Parameters<typeof renderDailyView>[0]}|null;
|
|
102
|
+
const actions=(view.actions as {key:string;label:string}[]|undefined)??[];
|
|
99
103
|
return ["PI-DADDY — CONNECTED HOST (snapshot; identity/acceptance not authenticated)",source?.daily?renderDailyView(source.daily,options.width):"Source unavailable; no previous acceptance substituted.",
|
|
100
104
|
view.debrief?renderDebrief(view.debrief as Parameters<typeof renderDebrief>[0],options.width):"Debrief deferred/closed. No implicit presentation or steering.",
|
|
101
|
-
`Attention reserved ${view.attention.attentionUsed}/5 (not proof of delivery). Tip ${view.tip}.`,
|
|
105
|
+
`Attention reserved ${view.attention.attentionUsed}/5 (not proof of delivery). Tip ${view.tip}.`,
|
|
106
|
+
actions.length?`ACTIONS — type one command and Enter\n${actions.map(action=>` ${action.key} — ${action.label}`).join("\n")}`:"ACTIONS — none currently authorized; refresh never acts."].join("\n\n");
|
|
102
107
|
}
|
|
103
108
|
if (options.debrief || options.debriefJson) {
|
|
104
109
|
if (!isDebriefPresenter(options.debrief)) return "PI-DADDY — DEBRIEF UNAVAILABLE: genuine presenter/host missing";
|
package/src/dashboard-harness.ts
CHANGED
|
@@ -6,6 +6,15 @@ import { dataDigest,byteDigest,detached,sha } from "./debrief-contract.ts";
|
|
|
6
6
|
import { DASHBOARD_HARNESS_PIN,type DashboardHarness } from "./dashboard-host-contract.ts";
|
|
7
7
|
export interface DashboardHarnessArtifact {version:"dashboard-harness-artifact-v1";sourceCommit:typeof DASHBOARD_HARNESS_PIN;files:Record<string,string>;typeboxRoot:string;typeboxPackageSha256:string}
|
|
8
8
|
const loaded=new WeakMap<object,string>();
|
|
9
|
+
export const DASHBOARD_HARNESS_BRIDGE_SOURCE="127b349310dd8f28e5d6b12148a063fce66a77dd";
|
|
10
|
+
const bridgeFunctions=["learningJournal","createTrustLifecycle","openTrustLifecycle","trustPolicyDigest","archivePolicyBinding","ingestPolicySource","readArchiveCheckpoint","readArchiveSource","retainArchiveSource","captureArchivedWorkSignals","readRetainedExecution","projectRetainedExecutions","createWorkCaseReviewer","createWorkSignalReviewer","retainBlindIntervention","openBlindIntervention"] as const;
|
|
11
|
+
/** Adopt the API published by the already-loaded skill-harness extension. Same-process source identity is not human authentication. */
|
|
12
|
+
export function adoptDashboardHarnessBridge(input:unknown):DashboardHarness{
|
|
13
|
+
const value=input as {version?:unknown;sourceCommit?:unknown;api?:unknown};
|
|
14
|
+
if(!value||typeof value!=="object"||Object.keys(value).sort().join()!=="api,sourceCommit,version"||value.version!=="skill-harness-dashboard-bridge-v1"||value.sourceCommit!==DASHBOARD_HARNESS_BRIDGE_SOURCE)throw Error("exact supported harness bridge required");
|
|
15
|
+
const api=value.api as DashboardHarness;if(!api||typeof api!=="object"||!Object.isFrozen(api)||bridgeFunctions.some(name=>typeof (api as unknown as Record<string,unknown>)[name]!=="function"))throw Error("frozen harness API with required source jobs required");
|
|
16
|
+
const digest=dataDigest({version:value.version,sourceCommit:value.sourceCommit,functions:bridgeFunctions});loaded.set(api,digest);return api;
|
|
17
|
+
}
|
|
9
18
|
export const loadedDashboardHarnessDigest=(h:unknown):string|null=>typeof h==="object"&&h!==null?loaded.get(h)??null:null;
|
|
10
19
|
async function privateDirectory(path:string){const stat=await lstat(path);if(!isAbsolute(path)||await realpath(path)!==path||!stat.isDirectory()||stat.mode&0o077||stat.uid!==process.getuid?.())throw Error("canonical private owned harness directory required");}
|
|
11
20
|
async function bytes(path:string){if(await realpath(path)!==path)throw Error("harness artifact alias");const fd=await open(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const stat=await fd.stat();if(!stat.isFile()||stat.nlink!==1||stat.mode&0o022||stat.size>1048576)throw Error("bounded immutable artifact required");const value=Buffer.alloc(stat.size+1),n=await fd.read(value,0,value.length,0),after=await fd.stat();if(n.bytesRead!==stat.size||after.size!==stat.size||after.mtimeMs!==stat.mtimeMs||after.ctimeMs!==stat.ctimeMs)throw Error("artifact changed during read");return value.subarray(0,n.bytesRead);}finally{await fd.close();}}
|
|
@@ -5,13 +5,16 @@ import type { WorkProjectionContext, WorkFrozen } from "./work-ledger.ts";
|
|
|
5
5
|
import type { DispatchAuthority } from "./dispatch-control.ts";
|
|
6
6
|
import type { OrdinaryAuthority } from "./ordinary-children.ts";
|
|
7
7
|
import type { ExperimentAuthority } from "./experiment-contract.ts";
|
|
8
|
-
export const DASHBOARD_HARNESS_PIN = "
|
|
8
|
+
export const DASHBOARD_HARNESS_PIN = "127b349310dd8f28e5d6b12148a063fce66a77dd";
|
|
9
9
|
export interface HostEvent { id: string; prior: string | null; value: Record<string, unknown> }
|
|
10
10
|
export interface HostJournal { read(): HostEvent[]; append(prior: string, value: Record<string, unknown>): HostEvent }
|
|
11
11
|
/** Loaded trusted ports; an interface or caller-supplied pin is NOT module/human authentication. */
|
|
12
12
|
export interface DashboardHarness extends DebriefHarness {
|
|
13
13
|
learningJournal(path: string, initial?: Record<string, unknown>): HostJournal;
|
|
14
14
|
openTrustLifecycle(path: string): { history(): HostEvent[]; inspect(now: number): { policyId: string; attentionUsed: number; tip: string; grantExpansion: false }; expose(id: string, now: number): { mode: string; reason: string; replayed: boolean } };
|
|
15
|
+
createTrustLifecycle(path:string,input:unknown,authorized:readonly string[]):unknown;
|
|
16
|
+
trustPolicyDigest(input:unknown):string;
|
|
17
|
+
retainArchiveSource(root:string,input:unknown):{manifestId:string};
|
|
15
18
|
archivePolicyBinding(path: string, source: string): { policySha256: string; archiveRoot: string };
|
|
16
19
|
ingestPolicySource(path: string, source: string, previous?: string, expectedPolicy?: string): { checkpointId: string; sourceStatus: string; issues?: string[]; [key: string]: unknown };
|
|
17
20
|
readArchiveCheckpoint(root: string, checkpoint: string): { checkpoint: { sourceManifestId: string; issues: string[]; [key: string]: unknown } };
|
|
@@ -4,7 +4,7 @@ import { dirname, isAbsolute } from "node:path";
|
|
|
4
4
|
import { parseRetentionJson } from "./retention-json.ts";
|
|
5
5
|
import { isDashboardHost, dashboardHostRequest, type DashboardHost, type DashboardHostRequest } from "./dashboard-host.ts";
|
|
6
6
|
export const ENV_DASHBOARD_HOST_SOCKET="PI_DADDY_HOST_SOCKET";
|
|
7
|
-
export type DashboardConnection={frame:DashboardHost["frame"];action:DashboardHost["action"]};
|
|
7
|
+
export type DashboardConnection={frame:DashboardHost["frame"];action:DashboardHost["action"];humanAction:DashboardHost["humanAction"]};
|
|
8
8
|
const connections=new WeakSet<object>();
|
|
9
9
|
export const isDashboardConnection=(v:unknown):v is DashboardConnection=>isDashboardHost(v)||typeof v==="object"&&v!==null&&connections.has(v);
|
|
10
10
|
async function parent(path:string){if(!isAbsolute(path)||Buffer.byteLength(path)>100||await realpath(dirname(path))!==dirname(path))throw Error("canonical bounded private dashboard socket required");const s=await lstat(dirname(path));if(!s.isDirectory()||s.mode&0o077||s.uid!==process.getuid?.())throw Error("private owned dashboard socket parent required");}
|
|
@@ -13,9 +13,11 @@ export async function serveDashboardHost(path:string,host:DashboardHost){
|
|
|
13
13
|
if(!isDashboardHost(host))throw Error("original dashboard host required");await parent(path);const clients=new Set<Socket>(),tasks=new Set<Promise<void>>();
|
|
14
14
|
const server=createServer(socket=>{if(clients.size>=4){socket.destroy();return;}clients.add(socket);socket.on("close",()=>clients.delete(socket));socket.on("error",()=>{});socket.setTimeout(5000,()=>socket.destroy());let size=0,used=false;const chunks:Buffer[]=[];
|
|
15
15
|
socket.on("data",bytes=>{if(used)return;size+=bytes.length;if(size>65536){socket.destroy();return;}chunks.push(bytes);if(bytes.at(-1)!==10)return;used=true;
|
|
16
|
-
const task=(async()=>{try{const text=new TextDecoder("utf-8",{fatal:true}).decode(Buffer.concat(chunks));const r=parseRetentionJson(text,65536) as {operation:string;request?:DashboardHostRequest};let result:unknown;
|
|
16
|
+
const task=(async()=>{try{const text=new TextDecoder("utf-8",{fatal:true}).decode(Buffer.concat(chunks));const r=parseRetentionJson(text,65536) as {operation:string;request?:DashboardHostRequest;key?:string};let result:unknown;
|
|
17
17
|
if(Object.keys(r).join()==="operation"&&r.operation==="frame")result=await host.frame();
|
|
18
|
-
else if(Object.keys(r).sort().join()==="operation,request"&&r.operation==="action")result=await host.action(dashboardHostRequest(r.request!));
|
|
18
|
+
else if(Object.keys(r).sort().join()==="operation,request"&&r.operation==="action")result=await host.action(dashboardHostRequest(r.request!));
|
|
19
|
+
else if(Object.keys(r).sort().join()==="key,operation"&&r.operation==="human-action"&&typeof r.key==="string")result=await host.humanAction(r.key);
|
|
20
|
+
else throw Error("closed dashboard protocol required");
|
|
19
21
|
const output=JSON.stringify({ok:true,result})+"\n";if(Buffer.byteLength(output)>512*1024)throw Error("dashboard frame bound exceeded");socket.end(output);
|
|
20
22
|
}catch{socket.end(JSON.stringify({ok:false,error:"refused or acknowledgement unknown; explicit readback only"})+"\n");}})();tasks.add(task);void task.finally(()=>tasks.delete(task));
|
|
21
23
|
});
|
|
@@ -26,13 +28,13 @@ export async function serveDashboardHost(path:string,host:DashboardHost){
|
|
|
26
28
|
}
|
|
27
29
|
/** Explicit operator-selected endpoint. Socket ownership is access policy, not module/human authentication. */
|
|
28
30
|
export function connectDashboardHost(path:string):DashboardConnection{
|
|
29
|
-
const call=async(operation:string,request?:DashboardHostRequest)=>{await parent(path);const s=await lstat(path);if(!s.isSocket()||s.mode&0o077||s.uid!==process.getuid?.())throw Error("private original dashboard endpoint unavailable");
|
|
31
|
+
const call=async(operation:string,payload:{request?:DashboardHostRequest;key?:string}={})=>{await parent(path);const s=await lstat(path);if(!s.isSocket()||s.mode&0o077||s.uid!==process.getuid?.())throw Error("private original dashboard endpoint unavailable");
|
|
30
32
|
return new Promise<any>((resolve,reject)=>{const socket=createConnection(path);let size=0,done=false;const chunks:Buffer[]=[];const fail=(e:unknown)=>{if(!done){done=true;socket.destroy();reject(e);}};
|
|
31
|
-
socket.setTimeout(5000,()=>fail(Error("dashboard acknowledgement timeout; do not repeat action")));socket.on("error",fail);socket.on("connect",()=>socket.write(JSON.stringify({operation,...
|
|
33
|
+
socket.setTimeout(5000,()=>fail(Error("dashboard acknowledgement timeout; do not repeat action")));socket.on("error",fail);socket.on("connect",()=>socket.write(JSON.stringify({operation,...payload})+"\n"));
|
|
32
34
|
socket.on("data",b=>{size+=b.length;if(size>512*1024){fail(Error("dashboard frame limit"));return;}chunks.push(b);});
|
|
33
35
|
socket.on("end",()=>{try{const text=new TextDecoder("utf-8",{fatal:true}).decode(Buffer.concat(chunks));const r=parseRetentionJson(text,512*1024) as {ok:boolean;result:unknown};if(r.ok!==true)throw Error("dashboard refused or acknowledgement unknown");if(operation==="frame"&&(r.result as {version?:string})?.version!=="producer-dashboard-frame-v1")throw Error("incompatible dashboard host");if(!done){done=true;resolve(r.result);}}catch(e){fail(e);}});
|
|
34
36
|
socket.on("close",()=>{if(!done)fail(Error("dashboard disconnected; acknowledgement unknown"));});
|
|
35
37
|
});
|
|
36
38
|
};
|
|
37
|
-
const api={frame:()=>call("frame"),action:(r:DashboardHostRequest)=>call("action",dashboardHostRequest(r))};connections.add(api);return Object.freeze(api);
|
|
39
|
+
const api={frame:()=>call("frame"),action:(r:DashboardHostRequest)=>call("action",{request:dashboardHostRequest(r)}),humanAction:(key:string)=>call("human-action",{key})};connections.add(api);return Object.freeze(api);
|
|
38
40
|
}
|
package/src/dashboard-host.ts
CHANGED
|
@@ -17,6 +17,10 @@ const hosts=new WeakSet<object>();
|
|
|
17
17
|
export const isDashboardHost=(value:unknown):value is DashboardHost=>typeof value==="object"&&value!==null&&hosts.has(value);
|
|
18
18
|
export type DashboardHost=ReturnType<typeof openDashboardHost>;
|
|
19
19
|
export interface DashboardHostOptions { ordinary?:OrdinaryChildren; harness:DashboardHarness; config:DashboardHostConfig; budget:GovernedBudgetBinding; experiment?:ReturnType<typeof openExperiment>; authority:()=>DashboardHostAuthority|null;
|
|
20
|
+
/** Exact actions constructed and authorized by the owner; the dashboard exposes only human labels/keys. */
|
|
21
|
+
humanActions?:(context:{hostDigest:string;selectionDigest:string;tip:string;observations:readonly {sourceId:string;checkpointId:string}[]})=>readonly {key:string;label:string;request:DashboardHostRequest}[]|Promise<readonly {key:string;label:string;request:DashboardHostRequest}[]>;
|
|
22
|
+
/** Explicit approved observation effect only; refresh/frame never invokes it. */
|
|
23
|
+
beforeObservation?:(sourceId:string)=>Promise<void>;
|
|
20
24
|
/** Independently sourced current declaration. Never inferred from idle, a PID, title or request fields. */
|
|
21
25
|
presence?:()=>{present:boolean;closing:boolean;evidenceDigest:string;expiresAt:number}|null;
|
|
22
26
|
}
|
|
@@ -53,6 +57,8 @@ export function openDashboardHost(options:DashboardHostOptions){
|
|
|
53
57
|
const append=(value:Record<string,unknown>)=>journal.append(history().at(-1)!.id,value);
|
|
54
58
|
const saved=():DebriefCheckpoint|null=>{const rows=history().filter(e=>e.value.type==="checkpoint");return rows.length?detached(rows.at(-1)!.value.checkpoint) as DebriefCheckpoint:null;};
|
|
55
59
|
let presenter:DebriefPresenter|undefined,busy=false,poisoned=false,presentationRevision:number|null=null,presenceDigest:string|null=null,attentionDeferred:string|null=null;
|
|
60
|
+
const ordinaryDispatchHoldKey=dataDigest({hostDigest,control:"pause-new-ordinary-dispatch"});
|
|
61
|
+
let displayedActions:null|{tip:string;actions:{key:string;label:string;operation:DashboardHostRequest["operation"];request:DashboardHostRequest}[];digest:string}=null;
|
|
56
62
|
const presence=()=>{const p=options.presence?.();return p&&p.present===true&&p.closing===true&&sha(p.evidenceDigest)&&Number.isFinite(p.expiresAt)&&p.expiresAt>Date.now()?p:null;};
|
|
57
63
|
const persistence:DebriefPersistence={durability:"host-owned",load:saved,compareAndSwap(expected,next){
|
|
58
64
|
const rows=history(),previous=saved();if((previous?dataDigest(previous):null)!==expected)throw Error("stale attention CAS");
|
|
@@ -65,6 +71,14 @@ export function openDashboardHost(options:DashboardHostOptions){
|
|
|
65
71
|
const paused=async(revision:number)=>{const b=await boundary();if(ordinary&&!ordinary.quiescent())throw Error("original ordinary children not quiescent");if(experiment){const view=await experiment.inspect();if(view.budget?.active!==0||view.control!=="not-assessed")throw Error("original experiment not quiescent");}if(!b.dispatch.paused||b.dispatch.admission!=="paused"||b.dispatch.revision!==revision||b.active!==0)throw Error("safe paused selection unavailable");};
|
|
66
72
|
const currentSelection=async()=>budget.binding.intent?(await budget.intentControls(null).inspect()).selection:c.selection;
|
|
67
73
|
const nativeRead=async()=>({dispatch:await budget.controls(null).inspect(),intent:budget.binding.intent?await budget.intentControls(null).inspect():null,experiment:experiment?await experiment.inspect():null,ordinary:ordinary?ordinary.inspect():null});
|
|
74
|
+
const availableHumanActions=async()=>{
|
|
75
|
+
const rows=history(),tip=rows.at(-1)!.id,selection=await currentSelection(),seen=new Set<string>();
|
|
76
|
+
const declared=await options.humanActions?.({hostDigest,selectionDigest:dashboardSelectionDigest(selection),tip,observations:observation.latest().map(x=>({sourceId:x.sourceId,checkpointId:x.checkpointId}))})??[],a=authority();
|
|
77
|
+
return declared.map(value=>{const x=detached(value);controlShape(x,["key","label","request"]);const request=dashboardHostRequest(x.request);
|
|
78
|
+
if(!/^[a-zA-Z0-9:_-]{1,64}$/.test(x.key)||typeof x.label!=="string"||!x.label.trim()||Buffer.byteLength(x.label)>120||seen.has(x.key))throw Error("invalid human dashboard action");seen.add(x.key);
|
|
79
|
+
if(request.hostDigest!==hostDigest||request.expectedTip!==tip||request.selectionDigest!==dashboardSelectionDigest(selection)||!a?.requestDigests.includes(dashboardHostRequestDigest(request)))throw Error("human dashboard action is stale or not exactly authorized");
|
|
80
|
+
return {key:x.key,label:x.label,operation:request.operation,request};});
|
|
81
|
+
};
|
|
68
82
|
const api={hostDigest,selectionDigest,
|
|
69
83
|
async frame(){
|
|
70
84
|
const rows=history(),tip=rows.at(-1)!.id,a=authority();let source:unknown=null,error:string|null=null;
|
|
@@ -74,14 +88,20 @@ export function openDashboardHost(options:DashboardHostOptions){
|
|
|
74
88
|
let debrief:unknown=null;
|
|
75
89
|
if(a&&presenter&&presentationRevision!==null&&presence()?.evidenceDigest===presenceDigest){try{await paused(presentationRevision);debrief=presenter.view();}catch{debrief=null;}}
|
|
76
90
|
let controls:unknown=null;try{controls=await nativeRead();}catch(e){error=String(e);}
|
|
77
|
-
const attention=trust.inspect(Date.now());
|
|
78
|
-
|
|
91
|
+
const attention=trust.inspect(Date.now());let actions:{key:string;label:string;operation:string}[]=[];
|
|
92
|
+
try{const available=await availableHumanActions(),digest=dataDigest(available.map(({key,label,operation,request})=>({key,label,operation,requestDigest:dashboardHostRequestDigest(request)})));
|
|
93
|
+
if(displayedActions?.tip===tip&&displayedActions.digest!==digest)throw Error("displayed dashboard actions changed before journal advance");
|
|
94
|
+
displayedActions={tip,actions:available,digest};actions=available.map(({key,label,operation})=>({key,label,operation}));}catch(e){error=String(e);}
|
|
95
|
+
return freeze(detached({version:"producer-dashboard-frame-v1",hostDigest,selectionDigest:dashboardSelectionDigest(selection),selectionState,tip,source,controls,debrief,attention,error,actions,
|
|
79
96
|
requests:rows.filter(e=>["claim","result","presentation","defer","ordinary-intent-pending"].includes(String(e.value.type))).map(e=>e.value),
|
|
80
97
|
control:rows.some(e=>e.value.type==="host-failure")?"failed":rows.some(e=>e.value.type==="claim"&&!rows.some(r=>r.value.type==="result"&&r.value.requestId===e.value.requestId))?"unknown":"not-assessed",
|
|
81
98
|
acknowledgement:poisoned?"unknown":"readback-only",identity:"independently-declared-host; not human/module authentication",activeBranch:null,acceptance:"not-assessed",freshness:"snapshot-unknown",workerInteractions:0}));
|
|
82
99
|
},
|
|
83
100
|
/** Explicit reconciliation is read-only. A retained claim is never replayed as an effect. */
|
|
84
101
|
reconcile:()=>api.frame(),
|
|
102
|
+
async humanAction(key:string){const tip=history().at(-1)!.id;if(!displayedActions||displayedActions.tip!==tip)throw Error("dashboard actions must be displayed at the current tip");
|
|
103
|
+
const found=displayedActions.actions.find(action=>action.key===key);if(!found)throw Error(`unknown dashboard action ${JSON.stringify(key)}`);
|
|
104
|
+
const current=(await availableHumanActions()).find(action=>action.key===key);if(!current||current.label!==found.label||dashboardHostRequestDigest(current.request)!==dashboardHostRequestDigest(found.request))throw Error("displayed dashboard action changed; no effect attempted");return api.action(found.request);},
|
|
85
105
|
async action(input:DashboardHostRequest){
|
|
86
106
|
const request=dashboardHostRequest(input),digest=dashboardHostRequestDigest(request);
|
|
87
107
|
if(busy||poisoned)throw Error("dashboard operation busy or acknowledgement unknown");busy=true;let attempted=false;
|
|
@@ -97,6 +117,7 @@ export function openDashboardHost(options:DashboardHostOptions){
|
|
|
97
117
|
let result:unknown,releaseOrdinary:(()=>void)|undefined;
|
|
98
118
|
try{
|
|
99
119
|
if(request.operation==="observe"){
|
|
120
|
+
const sourceId=(request.payload as {sourceId?:unknown})?.sourceId;if(typeof sourceId!=="string")throw Error("exact observation source required");await options.beforeObservation?.(sourceId);
|
|
100
121
|
const captured=observation.observe(request.payload,a!,selection);append({type:"observation",observation:captured});result=captured;
|
|
101
122
|
}else if(request.operation==="present"){
|
|
102
123
|
const p=detached(request.payload) as {userPresent:boolean;closing:boolean;evidenceDigest:string;dispatchRevision:number};
|
|
@@ -124,9 +145,11 @@ export function openDashboardHost(options:DashboardHostOptions){
|
|
|
124
145
|
presenter?.close();presentationRevision=null;
|
|
125
146
|
if(request.operation==="dispatch"||request.operation==="dispatch-reconcile"){
|
|
126
147
|
const native=dispatchRequest(request.payload as DispatchRequest);if(native.action==="cancel-execution")throw Error("cancellation needs exact separately approved native bridge");
|
|
127
|
-
const port=budget.controls(a!.dispatch);
|
|
148
|
+
const port=budget.controls(a!.dispatch),ordinaryHold=ordinary&&["pause-dispatch","resume-dispatch"].includes(native.action)?holdOrdinaryDispatch(ordinary,ordinaryDispatchHoldKey):undefined;
|
|
128
149
|
if(request.operation==="dispatch-reconcile"&&(await port.inspect()).records.find(r=>r.request.requestId===native.requestId)?.digest!==dispatchRequestDigest(native))throw Error("exact original dispatch reconciliation required");
|
|
129
150
|
result=request.operation==="dispatch"?await port.request(native):await port.reconcile(native.requestId);
|
|
151
|
+
const record=(result as {records:{request:{requestId:string};decision:string;application:string}[]}).records.find(r=>r.request.requestId===native.requestId);
|
|
152
|
+
if(ordinaryHold&&(record?.decision!=="approved"||native.action==="resume-dispatch"&&record.application==="applied"))ordinaryHold.release();
|
|
130
153
|
}else if(request.operation==="intent"||request.operation==="intent-reconcile"){
|
|
131
154
|
const native=detached(intentRequest(request.payload as IntentRequest)) as IntentRequest,port=budget.intentControls(a!.dispatch),digest=intentRequestDigest(native);
|
|
132
155
|
if(c.ordinaryDigest&&!ordinary)throw Error("original ordinary boundary unavailable; no recovery");
|
|
@@ -20,8 +20,9 @@ export function dashboardObservations(h: DashboardHarness, c: DashboardHostConfi
|
|
|
20
20
|
if(source.kind==="work"||source.kind==="facts"){
|
|
21
21
|
const checkpoint=h.readArchiveCheckpoint(root,captured.checkpointId).checkpoint;sourceManifestId=checkpoint.sourceManifestId;bytes(sourceManifestId);
|
|
22
22
|
if(source.kind==="facts") {if(input.facts!==null)throw Error("facts sources are declarations, not replacement authority");return {sourceId:source.id,kind:source.kind,checkpointId:captured.checkpointId,sourceManifestId,metadata:captured,cases:null,linkageManifestId:null};}
|
|
23
|
-
const factSource=latest().find(r=>r.kind==="facts"&&r.sourceManifestId===input.facts);if(
|
|
24
|
-
const facts=parseRetentionJson(new TextDecoder("utf-8",{fatal:true}).decode(bytes(factSource.sourceManifestId!)),256*1024);
|
|
23
|
+
const factSource=input.facts===null?null:latest().find(r=>r.kind==="facts"&&r.sourceManifestId===input.facts);if(input.facts!==null&&!factSource)throw Error("exact observed fact source required, not caller replacement facts");
|
|
24
|
+
const facts=factSource?parseRetentionJson(new TextDecoder("utf-8",{fatal:true}).decode(bytes(factSource.sourceManifestId!)),256*1024):null;
|
|
25
|
+
captured.factSourceManifestId=factSource?.sourceManifestId??null;captured.factBasis=factSource?"explicit-host-declarations-not-authenticated-truth":"derived-retained-work-only";
|
|
25
26
|
if(dataDigest(authority.workContext.selectedSnapshot)!==dataDigest(selection))throw Error("independent source selection changed");
|
|
26
27
|
try { const signals=h.captureArchivedWorkSignals(root,sourceManifestId,authority.workContext,facts);
|
|
27
28
|
cases={version:"work-signals-v1",batchId:signals.caseBatchId,observationId:signals.observationId};linkageManifestId=signals.linkageManifestId;
|