@staix/agent-hub 0.13.0 → 0.15.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 +30 -0
- package/README.md +19 -7
- package/docs/agent-notes/README.md +4 -0
- package/docs/agent-notes/adapters.md +18 -4
- package/docs/agent-notes/budget.md +5 -4
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +19 -7
- package/docs/agent-notes/tasks.md +15 -7
- package/docs/agent-notes/tests.md +4 -4
- package/docs/dogfood.md +18 -10
- package/docs/events.md +21 -7
- package/docs/operations.md +131 -27
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +13 -3
- package/docs/sdlc.md +40 -2
- package/docs/security.md +45 -14
- package/docs/specs/2026-09-19-agent-hub-design.md +112 -10
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +14 -0
- package/docs/verification/2026-10-23-0.14.0.md +69 -0
- package/docs/verification/2026-10-23-0.15.0.md +80 -0
- package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
- package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
- package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1078 -327
- package/src/adapters/acp.ts +43 -7
- package/src/adapters/claude-channel.ts +26 -4
- package/src/adapters/claude-implementer.ts +243 -0
- package/src/adapters/claude-print-child.ts +90 -0
- package/src/adapters/claude-task-sessions.ts +80 -0
- package/src/adapters/codex-review-isolation.ts +18 -7
- package/src/adapters/compat.ts +4 -0
- package/src/adapters/conformance/enrollment.ts +2 -1
- package/src/adapters/conformance/fakes/acp-server.ts +3 -1
- package/src/adapters/definitions/builtin-metadata.ts +15 -3
- package/src/adapters/definitions/native-channel-state.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/kimi-wire-usage.ts +303 -0
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +14 -2
- package/src/adapters/reviewer-print.ts +64 -60
- package/src/cli/console-config.ts +123 -0
- package/src/cli/console-state.ts +28 -1
- package/src/cli/console.ts +25 -1
- package/src/cli/facts-hook.ts +9 -5
- package/src/cli/help.ts +6 -4
- package/src/cli/launch.ts +10 -4
- package/src/cli/main.ts +52 -9
- package/src/cli/output.ts +75 -2
- package/src/cli/reset.ts +1 -1
- package/src/cli/settings-edit.ts +89 -0
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/terminal-recovery.ts +1 -6
- package/src/hub/action-enforce.ts +82 -0
- package/src/hub/action-policy.ts +22 -10
- package/src/hub/board.ts +48 -3
- package/src/hub/budget.ts +5 -0
- package/src/hub/bus.ts +5 -5
- package/src/hub/claude-launch-proof.ts +33 -0
- package/src/hub/config-apply.ts +5 -0
- package/src/hub/config-edit-host.ts +173 -0
- package/src/hub/config-edit.ts +282 -0
- package/src/hub/config-tier.ts +89 -0
- package/src/hub/control-client.ts +24 -3
- package/src/hub/daemon.ts +450 -71
- package/src/hub/deliverable-files.ts +131 -0
- package/src/hub/deliverables.ts +125 -0
- package/src/hub/delivery-journal.ts +18 -2
- package/src/hub/events.ts +3 -1
- package/src/hub/hub-tools.ts +21 -4
- package/src/hub/instances.ts +19 -6
- package/src/hub/kimi-usage.ts +144 -0
- package/src/hub/manager.ts +3 -3
- package/src/hub/peers.ts +7 -0
- package/src/hub/permission-payload.ts +15 -0
- package/src/hub/routing.ts +118 -3
- package/src/hub/sdlc-metrics.ts +260 -0
- package/src/hub/sdlc.ts +5 -3
- package/src/hub/settings.ts +1 -1
- package/src/hub/spec-read.ts +31 -0
- package/src/hub/task-code-group.ts +52 -0
- package/src/hub/tasks.ts +374 -37
- package/src/hub/token-window.ts +33 -0
- package/src/hub/ui.ts +3 -0
- package/src/hub/usage-rollup.ts +74 -6
- package/src/hub/workflow.ts +16 -0
- package/src/ui/index.html +388 -3
package/src/hub/action-policy.ts
CHANGED
|
@@ -31,24 +31,25 @@ export function actionPolicyConfig(value?: unknown): ActionPolicyConfig {
|
|
|
31
31
|
export interface ActionSessionBudget { readonly peer: string; readonly session: string }
|
|
32
32
|
export interface ActionChallenge { readonly classes: readonly ActionClass[] }
|
|
33
33
|
export type ActionDecision = { kind: "native" } | { kind: "ask"; classes: ActionClass[]; challenge?: ActionChallenge } | { kind: "budget"; classes: ActionClass[] };
|
|
34
|
-
|
|
34
|
+
/** `grants` counts the class-budget approvals of this session and policy: a challenge remembers it, so a later approval can tell that another one landed first. */
|
|
35
|
+
interface Session { handle: ActionSessionBudget; policy: string; remaining: Record<ActionClass, number>; grants: Record<ActionClass, number> }
|
|
35
36
|
const empty = (): Record<ActionClass, number> => ({ publish: 0, destroy: 0, "external-write": 0, install: 0 });
|
|
36
37
|
/** Host-only ledger: session ids and policy come from current runtime ports, never a tool argument. */
|
|
37
38
|
export class ActionBudgets {
|
|
38
39
|
private sessions = new Map<string, Session>();
|
|
39
|
-
private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[] }>();
|
|
40
|
+
private challenges = new WeakMap<ActionChallenge, { handle: ActionSessionBudget; policy: string; classes: ActionClass[]; grants: Record<ActionClass, number> }>();
|
|
40
41
|
constructor(private readonly config: () => ActionPolicyConfig | undefined, private readonly currentSession: (peer: string) => string | undefined) {}
|
|
41
42
|
bindSession(peer: string, session: string): ActionSessionBudget | undefined {
|
|
42
43
|
if (!session || this.currentSession(peer) !== session) return undefined;
|
|
43
44
|
const previous = this.sessions.get(peer);
|
|
44
45
|
if (previous?.handle.session === session) return previous.handle;
|
|
45
46
|
const handle = Object.freeze({ peer, session });
|
|
46
|
-
this.sessions.set(peer, { handle, policy: "", remaining: empty() });
|
|
47
|
+
this.sessions.set(peer, { handle, policy: "", remaining: empty(), grants: empty() });
|
|
47
48
|
return handle;
|
|
48
49
|
}
|
|
49
50
|
revoke(peer: string): void { this.sessions.delete(peer); }
|
|
50
51
|
policyChanged(): void {
|
|
51
|
-
for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); }
|
|
52
|
+
for (const state of this.sessions.values()) { state.policy = ""; state.remaining = empty(); state.grants = empty(); }
|
|
52
53
|
this.challenges = new WeakMap();
|
|
53
54
|
}
|
|
54
55
|
private current(handle: ActionSessionBudget | undefined): Session | undefined {
|
|
@@ -56,7 +57,7 @@ export class ActionBudgets {
|
|
|
56
57
|
const state = this.sessions.get(handle.peer);
|
|
57
58
|
if (state?.handle !== handle || this.currentSession(handle.peer) !== handle.session) return undefined;
|
|
58
59
|
const policy = JSON.stringify(actionPolicyConfig(this.config()));
|
|
59
|
-
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); }
|
|
60
|
+
if (state.policy !== policy) { state.policy = policy; state.remaining = empty(); state.grants = empty(); }
|
|
60
61
|
return state;
|
|
61
62
|
}
|
|
62
63
|
private selected(actions: readonly ActionClass[]): ActionClass[] {
|
|
@@ -70,28 +71,39 @@ export class ActionBudgets {
|
|
|
70
71
|
if (!state) return { kind: "ask", classes };
|
|
71
72
|
if (classes.some(key => state.remaining[key] <= 0)) {
|
|
72
73
|
const challenge = Object.freeze({ classes: Object.freeze([...classes]) });
|
|
73
|
-
this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes] });
|
|
74
|
+
this.challenges.set(challenge, { handle: state.handle, policy: state.policy, classes: [...classes], grants: { ...state.grants } });
|
|
74
75
|
return { kind: "ask", classes, challenge };
|
|
75
76
|
}
|
|
76
77
|
for (const key of classes) state.remaining[key]--;
|
|
77
78
|
return { kind: "budget", classes };
|
|
78
79
|
}
|
|
79
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Explicit class-budget approval only, never an ordinary allow_once; this invocation consumes slot one. Requests run concurrently, so
|
|
82
|
+
* two challenges can be issued while a class is spent: the first approval opens the allowance (budget minus this call), and an approval of
|
|
83
|
+
* a challenge issued before it is a later call inside that allowance, which consumes one slot instead of opening another. With no slot
|
|
84
|
+
* left it is refused (the person asks again and sees a fresh challenge), so N approved calls is the most any budget lets through.
|
|
85
|
+
* Every class is checked before any is spent: a refusal changes nothing.
|
|
86
|
+
*/
|
|
80
87
|
approve(challenge: ActionChallenge | undefined): boolean {
|
|
81
88
|
if (!challenge) return false;
|
|
82
89
|
const grant = this.challenges.get(challenge); this.challenges.delete(challenge);
|
|
83
90
|
if (!grant) return false;
|
|
84
91
|
const state = this.current(grant.handle), config = actionPolicyConfig(this.config());
|
|
85
92
|
if (!state || state.policy !== grant.policy) return false;
|
|
86
|
-
|
|
93
|
+
const late = (key: ActionClass) => state.grants[key] !== grant.grants[key];
|
|
94
|
+
if (grant.classes.some(key => late(key) && state.remaining[key] <= 0)) return false;
|
|
95
|
+
for (const key of grant.classes) {
|
|
96
|
+
state.remaining[key] = late(key) ? state.remaining[key] - 1 : Math.max(0, config.budgets[key] - 1);
|
|
97
|
+
state.grants[key]++;
|
|
98
|
+
}
|
|
87
99
|
return true;
|
|
88
100
|
}
|
|
89
101
|
/** Read-only projection: no session is created, rebound or reset by a status poll. */
|
|
90
|
-
status(peer: string, enforced: boolean) {
|
|
102
|
+
status(peer: string, enforced: boolean, reason = "adapter integration pending") {
|
|
91
103
|
const config = actionPolicyConfig(this.config()), state = this.sessions.get(peer);
|
|
92
104
|
const bound = !!state && this.currentSession(peer) === state.handle.session && state.policy === JSON.stringify(config);
|
|
93
105
|
return { enabled: config.enabled, alwaysAsk: [...config.always_ask], limits: { ...config.budgets }, remaining: bound ? { ...state.remaining } : empty(), sessionBound: bound,
|
|
94
|
-
enforceable: enforced, note: !enforced ? `always-ask not enforceable:
|
|
106
|
+
enforceable: enforced, note: !enforced ? `always-ask not enforceable: ${reason}${config.enabled ? "" : "; policy disabled"}` : config.enabled ? "always-ask active" : "always-ask disabled" };
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
|
package/src/hub/board.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { SweepRecord } from "./task-sweep.ts";
|
|
|
9
9
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
10
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
11
11
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
12
|
+
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
12
13
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
13
14
|
|
|
14
15
|
export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
|
|
@@ -66,6 +67,14 @@ export interface HistoryEntry {
|
|
|
66
67
|
reviewContext?: "fresh" | "continuing";
|
|
67
68
|
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
68
69
|
reviewFindings?: ReviewFindingSet;
|
|
70
|
+
/**
|
|
71
|
+
* #509a: the files a `done`, a lifecycle record or an approval bound, with the content hashes the hub computed. An approval carries the hashes
|
|
72
|
+
* it was given at review time, so a later edit reads as "deliverable changed since review". Full paths live here; every view goes through
|
|
73
|
+
* `publicDeliverables`.
|
|
74
|
+
*/
|
|
75
|
+
deliverables?: DeliverableRecord[];
|
|
76
|
+
/** #507b: the recorded acceptance criteria (S8 checklist ids) a verdict's unmet items named; derived by the hub from the checklist, never free text. */
|
|
77
|
+
unmetCriteria?: string[];
|
|
69
78
|
/** Host-authored plan content revision, separate from backlog planningRevision. */
|
|
70
79
|
planRevision?: { revision: number; fingerprint: string };
|
|
71
80
|
planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
|
|
@@ -77,6 +86,11 @@ export interface HistoryEntry {
|
|
|
77
86
|
reviewerFailure?: { peer: string; head?: string; cause: "start_failed" | "quota" | "no_verdict" };
|
|
78
87
|
planning?: { revision: number; fields: (keyof PlanningFields)[]; previous: PlanningFields; pull?: true; split?: { children: { id: number; revision: number; history: number }[] } };
|
|
79
88
|
}
|
|
89
|
+
/** The ids a stored verdict may carry (#507b): bounded criterion identifiers in the S8 checklist's own format, at most its 32, each once. */
|
|
90
|
+
function criterionIdList(value: unknown): string[] {
|
|
91
|
+
if (!Array.isArray(value) || value.length > 32 || value.some(id => typeof id !== "string" || !/^[A-Za-z][A-Za-z0-9-]{0,47}$/.test(id)) || new Set(value).size !== value.length) throw new Error("unmet criteria: expected up to 32 distinct criterion ids");
|
|
92
|
+
return [...value] as string[];
|
|
93
|
+
}
|
|
80
94
|
export type ReviewerHistory = Pick<HistoryEntry, "reviewerFamily" | "reviewerReason" | "reviewBlocked" | "reviewerFailure">;
|
|
81
95
|
function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
82
96
|
const id = (value: unknown) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
@@ -222,6 +236,8 @@ export class Board {
|
|
|
222
236
|
// And from before issue #207: existing rows reserve nobody.
|
|
223
237
|
for (const col of ["offer", "cancelled"]) if (!have.has(col)) this.db.run(`ALTER TABLE tasks ADD COLUMN ${col} TEXT`);
|
|
224
238
|
this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
|
|
239
|
+
// #481 S7: one row per log-tier band breach episode already announced, so a restart never repeats the notice.
|
|
240
|
+
this.db.run("CREATE TABLE IF NOT EXISTS band_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
|
|
225
241
|
// Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
|
|
226
242
|
this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
|
|
227
243
|
this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
|
|
@@ -300,6 +316,11 @@ export class Board {
|
|
|
300
316
|
this.taskWrite(() => this.db.query("INSERT INTO task_stalls(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(stall.task, JSON.stringify(stall)));
|
|
301
317
|
return true;
|
|
302
318
|
}
|
|
319
|
+
/** True when the marker was new (and is now recorded), false when it was announced before. Ids, metric names and task numbers only. */
|
|
320
|
+
recordBandNotice(marker: string): boolean {
|
|
321
|
+
if (!/^[a-z_]{1,64}:#[1-9][0-9]{0,9}:log$/.test(marker)) throw new Error("invalid band notice marker");
|
|
322
|
+
return this.db.query("INSERT OR IGNORE INTO band_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
|
|
323
|
+
}
|
|
303
324
|
clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
|
|
304
325
|
stalls(): TaskStall[] {
|
|
305
326
|
return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
|
|
@@ -325,7 +346,7 @@ export class Board {
|
|
|
325
346
|
|
|
326
347
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
327
348
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
328
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
349
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
329
350
|
const task = this.get(id);
|
|
330
351
|
if (!task) throw new Error(`no task #${id}`);
|
|
331
352
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -359,6 +380,16 @@ export class Board {
|
|
|
359
380
|
if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
|
|
360
381
|
reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
|
|
361
382
|
}
|
|
383
|
+
let deliverables: DeliverableRecord[] | undefined;
|
|
384
|
+
if (extra.deliverables !== undefined) {
|
|
385
|
+
if (!["done", "done (checking)", "lifecycle", "approved"].includes(event)) throw new Error("deliverable records require a done, a lifecycle record or an approval");
|
|
386
|
+
deliverables = parseDeliverableRecords(extra.deliverables);
|
|
387
|
+
}
|
|
388
|
+
let unmetCriteria: string[] | undefined;
|
|
389
|
+
if (extra.unmetCriteria !== undefined) {
|
|
390
|
+
if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("unmet criteria require a saved review verdict");
|
|
391
|
+
unmetCriteria = criterionIdList(extra.unmetCriteria);
|
|
392
|
+
}
|
|
362
393
|
if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
|
|
363
394
|
const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
|
|
364
395
|
const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
|
|
@@ -367,7 +398,7 @@ export class Board {
|
|
|
367
398
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
368
399
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
369
400
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
370
|
-
const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
401
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
371
402
|
this.taskWrite(() => {
|
|
372
403
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
373
404
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id);
|
|
@@ -403,12 +434,26 @@ export class Board {
|
|
|
403
434
|
const task = this.get(id);
|
|
404
435
|
if (!task || (task.planningRevision ?? 0) !== revision || task.state !== "backlog" || task.owner || !taskCanPlan(task)) throw new Error("planner pull changed or already started");
|
|
405
436
|
const now = Date.now(), entry: HistoryEntry = { at: now, by, event: "backlog pulled", planning: { revision: revision + 1, fields: ["reserved"], previous: { reserved: task.reserved ?? null }, pull: true }, note, ...(withheld ? { withheld: true as const } : {}) };
|
|
406
|
-
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
437
|
+
const result = this.taskWrite(() => this.db.query("UPDATE tasks SET state='proposed',owner=NULL,reviewer=NULL,offer=NULL,reserved=?,planning_revision=planning_revision+1,history=?,updated=? WHERE id=? AND planning_revision=? AND state='backlog' AND owner IS NULL")
|
|
407
438
|
.run(reserved, JSON.stringify([...task.history, entry]), now, id, revision));
|
|
408
439
|
if (result.changes !== 1) throw new Error("planner pull changed");
|
|
409
440
|
const updated = this.get(id)!; this.changed(updated, entry); return updated;
|
|
410
441
|
}
|
|
411
442
|
|
|
443
|
+
/** Publish no intermediate ownerless pull: Tasks has already admitted the exact reserved peer. */
|
|
444
|
+
pullAssigned(id: number, by: PeerId, owner: PeerId, revision: number, note: string, withheld: boolean,
|
|
445
|
+
reviewer: PeerId | null, assignmentNote: string, extra: Parameters<Board["update"]>[5], expectedFlowRevision: number): Task {
|
|
446
|
+
return this.atomic(() => {
|
|
447
|
+
const current = this.get(id);
|
|
448
|
+
if (!current || current.history.length !== expectedFlowRevision) throw new Error("task flow revision changed");
|
|
449
|
+
const pulled = this.pullPlanned(id, by, owner, revision, note, withheld);
|
|
450
|
+
const assigned = this.update(id, by, "assigned", { owner, reviewer }, assignmentNote, extra, pulled.history.length);
|
|
451
|
+
// Both deferred events describe the committed handoff, including the callback payload.
|
|
452
|
+
for (const change of this.pendingChanges ?? []) if (change.task.id === id) change.task = assigned;
|
|
453
|
+
return assigned;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
412
457
|
/** No owner or execution evidence may be taken back by planning undo. */
|
|
413
458
|
undoPlannedPull(id: number, by: PeerId, revision: number): Task {
|
|
414
459
|
const task = this.get(id), pull = task ? planningUndoEntry(task)?.planning : undefined;
|
package/src/hub/budget.ts
CHANGED
|
@@ -248,6 +248,11 @@ export class Budget {
|
|
|
248
248
|
this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/** A source that can no longer be trusted drops its reading of one window: the window shows unknown, never the last number. */
|
|
252
|
+
forget(peer: PeerId, id: UsageWindow["id"]): void {
|
|
253
|
+
this.readings.get(this.account(peer))?.delete(id);
|
|
254
|
+
}
|
|
255
|
+
|
|
251
256
|
/** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
|
|
252
257
|
private fresh(peer: PeerId): Reading[] {
|
|
253
258
|
peer = this.account(peer);
|
package/src/hub/bus.ts
CHANGED
|
@@ -429,14 +429,14 @@ export class Bus {
|
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
knownPeers(): string[] {
|
|
432
|
-
const pending = this.storageError ? [] : this.journal?.
|
|
432
|
+
const pending = this.storageError ? [] : this.journal?.pendingPeers() ?? [];
|
|
433
433
|
return [...new Set([...this.peers.keys(), ...[...this.queues].filter(([, queue]) => queue.length).map(([id]) => id), ...pending])].sort();
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
queueList(peer?: string): JournalDelivery[] {
|
|
436
|
+
queueList(peer?: string, unresolvedOnly = false): JournalDelivery[] {
|
|
437
437
|
if (this.journal) {
|
|
438
|
-
const rows = this.journal.list(peer).filter((row) => !row.reason?.startsWith("grouped into delivery "));
|
|
439
|
-
const revision = this.journal.
|
|
438
|
+
const rows = (unresolvedOnly ? this.journal.unresolved(peer) : this.journal.list(peer)).filter((row) => !row.reason?.startsWith("grouped into delivery "));
|
|
439
|
+
const revision = this.journal.revision();
|
|
440
440
|
const recorded = new Set(rows.filter((r) => ["queued", "dispatching", "accepted", "needs_review"].includes(r.state)).flatMap((r) => (r.originals as Envelope[]).map((e) => `${r.peer}:${e.id}`)));
|
|
441
441
|
const ids = peer ? [peer] : [...this.queues.keys()];
|
|
442
442
|
for (const p of ids) for (const e of this.queues.get(p) ?? []) if (!recorded.has(`${p}:${e.id}`)) rows.push({ id: `q:${p}:${e.id}`, peer: p, state: "queued", revision, createdAt: e.ts, updatedAt: e.ts, originals: [e], out: [e] });
|
|
@@ -525,7 +525,7 @@ export class Bus {
|
|
|
525
525
|
}
|
|
526
526
|
|
|
527
527
|
queueSummary(peer: string): { needsReview: number; heldBy?: string; liveAccepted?: string[]; oldestQueuedAt?: number } {
|
|
528
|
-
const rows = this.storageError ? [] : this.queueList(peer);
|
|
528
|
+
const rows = this.storageError ? [] : this.queueList(peer, true);
|
|
529
529
|
const queued = this.queues.get(peer) ?? [];
|
|
530
530
|
const review = rows.filter((r) => r.state === "needs_review");
|
|
531
531
|
const needsReview = review.length;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync, constants } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { claudeStateFile } from "./claude-state.ts";
|
|
4
|
+
|
|
5
|
+
export interface ClaudeLaunchProof {
|
|
6
|
+
launchId: string;
|
|
7
|
+
executionCwd?: string;
|
|
8
|
+
privateRecord: boolean;
|
|
9
|
+
permissionHook: boolean;
|
|
10
|
+
unattended: boolean;
|
|
11
|
+
hookPurpose?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One bounded reader for current managed launch metadata; no native identity or payload escapes. */
|
|
15
|
+
export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchProof | undefined {
|
|
16
|
+
let fd: number | undefined;
|
|
17
|
+
try {
|
|
18
|
+
fd = openSync(join(stateDir, claudeStateFile("claude-launch", peer)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
19
|
+
const stat = fstatSync(fd);
|
|
20
|
+
// ponytail: launch metadata is capped at 16 KiB; add a versioned projection if it ever grows.
|
|
21
|
+
if (!stat.isFile() || stat.size > 16 * 1024) return;
|
|
22
|
+
const bytes = Buffer.alloc(16 * 1024 + 1);
|
|
23
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
24
|
+
if (count > 16 * 1024) return;
|
|
25
|
+
const row = JSON.parse(bytes.subarray(0, count).toString("utf8"));
|
|
26
|
+
if (row?.instanceId !== instanceId || typeof row.launchId !== "string" || !row.launchId || row.launchId.length > 128) return;
|
|
27
|
+
return { launchId: row.launchId, privateRecord: stat.nlink === 1 && (stat.mode & 0o777) === 0o600 && !!process.getuid && stat.uid === process.getuid(),
|
|
28
|
+
...(typeof row.executionCwd === "string" && row.executionCwd.length <= 4096 && isAbsolute(row.executionCwd) ? { executionCwd: row.executionCwd } : {}),
|
|
29
|
+
permissionHook: row.permissionHook === true, unattended: row.unattended === true,
|
|
30
|
+
...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
|
|
31
|
+
} catch { return; }
|
|
32
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
33
|
+
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { externalMetadataDefinitions, type ExternalAdapterMetadata } from "../ad
|
|
|
5
5
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
6
6
|
import type { HubConfig } from "./daemon.ts";
|
|
7
7
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
8
|
+
import { structuralLeaf } from "./config-tier.ts";
|
|
8
9
|
import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
9
10
|
import { CLASSES } from "./board.ts";
|
|
10
11
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
@@ -44,6 +45,8 @@ export interface ConfigAuthorizationInput {
|
|
|
44
45
|
acceptedFallback?: boolean;
|
|
45
46
|
/** The prospective settled planner/conductor holder sets change, even if the raw role list shrinks. */
|
|
46
47
|
settledHolderChange?: boolean;
|
|
48
|
+
/** #689: the daemon sets this only for a structural edit it validated and wrote itself; `structuralLeaf` still decides the key. */
|
|
49
|
+
structural?: boolean;
|
|
47
50
|
guard?: ReloadGuard;
|
|
48
51
|
}
|
|
49
52
|
export interface ConfigAuthorizationDecision { key: string; allowed: boolean; guarded: boolean; reason: string }
|
|
@@ -90,6 +93,8 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
|
|
|
90
93
|
// Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
|
|
91
94
|
if (input.tracked !== false) return result(false, true, input.tracked ? "guarded source is tracked; retain the running value" : "guarded source trust is unknown; retain the running value");
|
|
92
95
|
if (input.authority === "ordinary") return result(false, true, "guarded change needs a selected setting or a confirmed terminal reload");
|
|
96
|
+
// #689: a structural key (never an executable or secret one) written by the daemon's own validated edit, from a settings session.
|
|
97
|
+
if (input.authority === "settings" && !guard && input.structural === true && input.confirmed === true && input.source === "config" && input.sourceFile === "config.local.json" && structuralLeaf(key)) return result(true, true, "confirmed structural edit");
|
|
93
98
|
if (input.authority === "settings" && !guard) return result(false, true, "nonregistered policy requires a confirmed terminal reload");
|
|
94
99
|
if (input.confirmed !== true) return result(false, true, "guarded change needs explicit confirmation for this key");
|
|
95
100
|
return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { configBackups, parseConfigEditRequest, viewRevisions, planConfigEdit, writeConfigEdit, type CandidateCheck, type ConfigEditPlan } from "./config-edit.ts";
|
|
2
|
+
import { configTier, maskTier3, TIER2_INSTANCE_FIELDS, TIER3_INSTANCE_FIELDS, TIER3_PREFIXES, type ConfigEditFile } from "./config-tier.ts";
|
|
3
|
+
import type { HubEvent } from "./events.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #689: the daemon's side of structural config edits. Everything the daemon decides is here, with its ports injected, so the
|
|
7
|
+
* daemon itself carries only a few lines of wiring: who may edit (authority), the typed confirmation for tier 3, the hold during
|
|
8
|
+
* recovery or shutdown, the audit event and the scoped reload that applies or marks each key.
|
|
9
|
+
*/
|
|
10
|
+
export type EditAuthority = "ordinary" | "settings" | "terminal";
|
|
11
|
+
export interface ConfigEditHost {
|
|
12
|
+
cwd: string;
|
|
13
|
+
stateDir: string;
|
|
14
|
+
/** The loaders' verdict on a candidate file; throws the loader's reason. */
|
|
15
|
+
check: CandidateCheck;
|
|
16
|
+
/** A reason edits are held now (recovery or shutdown), or undefined. */
|
|
17
|
+
held: () => string | undefined;
|
|
18
|
+
/** Publishes one changed key through the daemon's only reload path, scoped to it. */
|
|
19
|
+
reload: (authority: EditAuthority, key: string) => Promise<Record<string, unknown>>;
|
|
20
|
+
event: (event: Extract<HubEvent, { type: "config_edit" }>) => void;
|
|
21
|
+
notify: (text: string) => void;
|
|
22
|
+
/** Called after a write so status.json and the console snapshot show what is now pending. */
|
|
23
|
+
changed: () => void;
|
|
24
|
+
/** What waits for a restart now, from the reload's own bookkeeping (instance declarations are restart-bound). */
|
|
25
|
+
pending: () => { key: string; reason: string }[];
|
|
26
|
+
/** The running hub's value at a config path, for the form. */
|
|
27
|
+
effective: (path: readonly string[]) => unknown;
|
|
28
|
+
/** The lifecycle the running hub holds, to say what a changed sdlc.toml waits for. */
|
|
29
|
+
runningSdlc: () => { mode: string; requiredChecks: readonly string[] };
|
|
30
|
+
/** The lifecycle the file would give at the next start, or the loader's reason. */
|
|
31
|
+
fileSdlc: () => { mode: string; requiredChecks: readonly string[] } | string;
|
|
32
|
+
/** The declared instances of the running hub: id to its tier 2 fields. */
|
|
33
|
+
declared: () => Record<string, Record<string, unknown>>;
|
|
34
|
+
/** config.local.json as the file holds it now, parsed once per call (`{}` when it is missing or unreadable). Never sent anywhere: it is read for what is set, never shown. */
|
|
35
|
+
localConfig: () => Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
export interface ConfigEditView {
|
|
38
|
+
revisions: Record<ConfigEditFile, string>;
|
|
39
|
+
instances: { id: string; fields: Record<string, unknown>; tier3: string[] }[];
|
|
40
|
+
/** The instance ids config.local.json holds now, including ones the running hub has not started with yet: the forms change those leaf by leaf. */
|
|
41
|
+
known: string[];
|
|
42
|
+
blocks: { path: string; value: unknown }[];
|
|
43
|
+
sdlc: { mode: string; requiredChecks: readonly string[]; pending: boolean; problem?: string };
|
|
44
|
+
/** Tier 3 keys with the console command that changes them; the dashboard shows exactly this, read-only. */
|
|
45
|
+
tier3: { key: string; set: boolean; command: string }[];
|
|
46
|
+
backups: { name: string; file: ConfigEditFile; at: number; bytes: number }[];
|
|
47
|
+
fields: { instance: readonly string[]; tier3Instance: readonly string[] };
|
|
48
|
+
}
|
|
49
|
+
const REASON_BYTES = 400;
|
|
50
|
+
const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
|
|
51
|
+
|
|
52
|
+
export const CONFIG_EDIT_OPS = ["config_edit_get", "config_edit_preview", "config_edit_apply"] as const;
|
|
53
|
+
|
|
54
|
+
export function createConfigEditor(host: ConfigEditHost) {
|
|
55
|
+
const sdlcPending = (): boolean => {
|
|
56
|
+
const file = host.fileSdlc(), running = host.runningSdlc();
|
|
57
|
+
return typeof file !== "string" && (file.mode !== running.mode || JSON.stringify(file.requiredChecks) !== JSON.stringify(running.requiredChecks));
|
|
58
|
+
};
|
|
59
|
+
/** What a changed sdlc.toml waits for: shown with the other pending restarts. */
|
|
60
|
+
const sdlcRestart = (): { key: string; reason: string }[] => {
|
|
61
|
+
const file = host.fileSdlc(), running = host.runningSdlc();
|
|
62
|
+
if (typeof file === "string") return [];
|
|
63
|
+
return [...(file.mode !== running.mode ? [{ key: "sdlc.mode", reason: "the lifecycle is read once at hub start" }] : []),
|
|
64
|
+
...(JSON.stringify(file.requiredChecks) !== JSON.stringify(running.requiredChecks) ? [{ key: "sdlc.required_checks", reason: "the lifecycle is read once at hub start" }] : [])];
|
|
65
|
+
};
|
|
66
|
+
const pendingRestart = (): { key: string; reason: string }[] => sdlcRestart();
|
|
67
|
+
const allPending = (): string[] => [...host.pending().map(row => row.key), ...sdlcRestart().map(row => row.key)];
|
|
68
|
+
function view(): ConfigEditView {
|
|
69
|
+
const file = host.fileSdlc(), running = host.runningSdlc();
|
|
70
|
+
let local: Record<string, unknown> = {};
|
|
71
|
+
try { local = host.localConfig(); } catch { /* the revisions say the file is unavailable */ }
|
|
72
|
+
const fileInstances = local.instances && typeof local.instances === "object" && !Array.isArray(local.instances) ? Object.keys(local.instances) : [];
|
|
73
|
+
const isSet = (path: readonly string[]): boolean => { let node: unknown = local; for (const part of path) { if (!node || typeof node !== "object" || !Object.hasOwn(node, part)) return false; node = (node as Record<string, unknown>)[part]; } return node !== undefined; };
|
|
74
|
+
const declared = host.declared();
|
|
75
|
+
return {
|
|
76
|
+
revisions: viewRevisions(host.cwd),
|
|
77
|
+
known: fileInstances,
|
|
78
|
+
instances: Object.entries(declared).map(([id, fields]) => ({ id, fields: Object.fromEntries(TIER2_INSTANCE_FIELDS.filter(field => fields[field] !== undefined).map(field => [field, fields[field]])),
|
|
79
|
+
tier3: TIER3_INSTANCE_FIELDS.filter(field => fields[field] !== undefined) as string[] })),
|
|
80
|
+
blocks: BLOCKS.map(path => ({ path: path.join("."), value: host.effective(path) ?? null })),
|
|
81
|
+
sdlc: { mode: running.mode, requiredChecks: running.requiredChecks, pending: sdlcPending(), ...(typeof file === "string" ? { problem: file } : {}) },
|
|
82
|
+
tier3: [...TIER3_PREFIXES.map(key => ({ key, set: isSet(key.split(".")), command: `ahub settings edit config.local.json set ${key} <json>` })),
|
|
83
|
+
...Object.entries(declared).flatMap(([id, fields]) => TIER3_INSTANCE_FIELDS.filter(field => fields[field] !== undefined).map(field => ({ key: `instances.${id}.${field}`, set: true, command: `ahub settings edit config.local.json set instances.${id}.${field} <json>` })))],
|
|
84
|
+
backups: configBackups(host.stateDir).slice(0, 10),
|
|
85
|
+
fields: { instance: TIER2_INSTANCE_FIELDS, tier3Instance: TIER3_INSTANCE_FIELDS },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** A refusal before any loader runs: an unauthorized or unclassifiable request learns nothing about the project. */
|
|
89
|
+
function authorize(raw: unknown, authority: EditAuthority): { ok: true; tier3: string[] } | { ok: false; error: string } {
|
|
90
|
+
if (authority === "ordinary") return { ok: false, error: "structural config edits need a settings session: open one with ahub ui --settings, or use the console" };
|
|
91
|
+
const request = parseConfigEditRequest(raw);
|
|
92
|
+
if (typeof request === "string") return { ok: false, error: request };
|
|
93
|
+
const tier3: string[] = [];
|
|
94
|
+
for (const { path, value } of request.set) {
|
|
95
|
+
const tier = configTier(request.file, path, value);
|
|
96
|
+
if ("refused" in tier) return { ok: false, error: tier.refused };
|
|
97
|
+
if (tier.tier === 3) tier3.push(tier.key);
|
|
98
|
+
}
|
|
99
|
+
if (tier3.length && authority !== "terminal") return { ok: false, error: `${tier3[0]} chooses what the hub runs or where secrets go: it is never editable from the dashboard; change it in the console (ahub settings edit config.local.json set ${tier3[0]} <json>)` };
|
|
100
|
+
if (tier3.length > 1) return { ok: false, error: "change one executable or secret-locating key at a time: each needs its own typed confirmation" };
|
|
101
|
+
return { ok: true, tier3 };
|
|
102
|
+
}
|
|
103
|
+
function planned(raw: unknown, authority: EditAuthority): { ok: true; plan: Extract<ConfigEditPlan, { ok: true }> } | { ok: false; error: string; field?: string } {
|
|
104
|
+
const allowed = authorize(raw, authority);
|
|
105
|
+
if (!allowed.ok) return allowed;
|
|
106
|
+
const held = host.held();
|
|
107
|
+
if (held) return { ok: false, error: `${held} is holding config edits` };
|
|
108
|
+
const plan = planConfigEdit(host.cwd, raw, host.check);
|
|
109
|
+
if (!plan.ok) return plan;
|
|
110
|
+
// The plan reads the file itself: if it finds a tier 3 key the request alone did not show, a dashboard still learns nothing.
|
|
111
|
+
if (plan.tier === 3 && authority !== "terminal") return { ok: false, error: `${plan.tier3Keys[0]} chooses what the hub runs or where secrets go: it is never editable from the dashboard; change it in the console` };
|
|
112
|
+
// The request names some tier 3 keys and the file adds others (an instance it holds a home for): one write changes exactly one, and each is typed.
|
|
113
|
+
if (plan.tier3Keys.length > 1) return { ok: false, error: "change one executable or secret-locating key at a time: each needs its own typed confirmation" };
|
|
114
|
+
return { ok: true, plan };
|
|
115
|
+
}
|
|
116
|
+
function diffText(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
|
|
117
|
+
const show = (path: string, value: unknown): string => { const masked = maskTier3(path.split("."), value); return masked === undefined ? "(unset)" : JSON.stringify(masked); };
|
|
118
|
+
return plan.diff.map(row => `${row.path}: ${show(row.path, row.before)} -> ${show(row.path, row.after)} (${row.applies === "restart" ? "pending restart" : "applies at next use"})`);
|
|
119
|
+
}
|
|
120
|
+
function preview(raw: unknown, authority: EditAuthority): Record<string, unknown> {
|
|
121
|
+
const result = planned(raw, authority);
|
|
122
|
+
if (!result.ok) return { ok: false, error: result.error, ...(result.field ? { field: result.field } : {}) };
|
|
123
|
+
const { plan } = result;
|
|
124
|
+
return { ok: true, file: plan.request.file, revision: plan.request.revision, tier: plan.tier, lines: [...diffText(plan), ...plan.notes],
|
|
125
|
+
diff: plan.diff.map(row => ({ path: row.path, tier: row.tier, applies: row.applies, before: maskTier3(row.path.split("."), row.before) ?? null, after: maskTier3(row.path.split("."), row.after) ?? null })),
|
|
126
|
+
...(plan.tier === 3 ? { confirm: plan.tier3Keys[0], warning: "this runs code with the daemon's rights, or chooses where secrets are read from" } : {}) };
|
|
127
|
+
}
|
|
128
|
+
async function apply(raw: unknown, authority: EditAuthority, confirm?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
|
|
129
|
+
let reason: string | undefined;
|
|
130
|
+
if (rawReason !== undefined) {
|
|
131
|
+
if (typeof rawReason !== "string" || !rawReason.trim() || Buffer.byteLength(rawReason) > REASON_BYTES) return { ok: false, error: `reason must contain 1..${REASON_BYTES} UTF-8 bytes` };
|
|
132
|
+
reason = rawReason.trim().replace(/[\x00-\x1f\x7f]/g, " ");
|
|
133
|
+
}
|
|
134
|
+
const result = planned(raw, authority);
|
|
135
|
+
if (!result.ok) return { ok: false, error: result.error, ...(result.field ? { field: result.field } : {}) };
|
|
136
|
+
const { plan } = result;
|
|
137
|
+
if (plan.tier === 3 && confirm !== plan.tier3Keys[0]) return { ok: false, error: `${plan.tier3Keys[0]} runs code with the daemon's rights or locates secrets: type the key name to confirm; nothing was written`, confirm: plan.tier3Keys[0] };
|
|
138
|
+
let written: ReturnType<typeof writeConfigEdit>;
|
|
139
|
+
try { written = writeConfigEdit(host.cwd, host.stateDir, plan); }
|
|
140
|
+
catch (error) { return { ok: false, error: `${(error as Error).message}; nothing was changed` }; }
|
|
141
|
+
const source = authority === "terminal" ? "terminal" as const : "dashboard" as const;
|
|
142
|
+
host.event({ type: "config_edit", file: plan.request.file, paths: written.paths, fileBefore: written.fileBefore, fileAfter: written.fileAfter, tier: plan.tier, by: "person", source, ...(written.backup ? { backup: written.backup } : {}), ...(reason ? { reason } : {}) });
|
|
143
|
+
try { host.notify(`config edit: ${plan.request.file} ${plan.diff.map(row => row.path).join(", ")} (${source})`); } catch { /* the write stands */ }
|
|
144
|
+
const settled = () => { try { host.changed(); } catch { /* the write stands; the next status write shows it */ } };
|
|
145
|
+
if (plan.request.file === "sdlc.toml") { settled(); return { ok: true, saved: true, ...(written.backup ? { backup: written.backup } : {}), lines: diffText(plan), text: `sdlc.toml written; the lifecycle is read once at hub start: restart pending`, restartPending: allPending() }; }
|
|
146
|
+
const failures: string[] = [];
|
|
147
|
+
for (const key of leafKeys(plan)) {
|
|
148
|
+
let reloaded: Record<string, unknown>;
|
|
149
|
+
try { reloaded = await host.reload(authority, key); } catch { reloaded = { ok: false, error: "the change was saved but could not be published; inspect hub.log" }; }
|
|
150
|
+
if (reloaded.ok !== true) failures.push(`${key}: ${String(reloaded.error ?? "not published")}`);
|
|
151
|
+
}
|
|
152
|
+
settled();
|
|
153
|
+
if (failures.length) return { ok: false, saved: true, error: `saved, but not published: ${failures.join("; ")}; run ahub settings reload to review it`, ...(written.backup ? { backup: written.backup } : {}) };
|
|
154
|
+
return { ok: true, saved: true, ...(written.backup ? { backup: written.backup } : {}), lines: diffText(plan), text: `config.local.json written: ${plan.diff.map(row => row.path).join(", ")}${plan.diff.some(row => row.applies === "restart") ? "; restart pending" : ""}`, restartPending: allPending() };
|
|
155
|
+
}
|
|
156
|
+
return { view, preview, apply, pendingRestart };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The leaf keys a plan changed, so each is published through its own scoped reload. */
|
|
160
|
+
function leafKeys(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
|
|
161
|
+
const out = new Set<string>();
|
|
162
|
+
const walk = (prefix: string, before: unknown, after: unknown): void => {
|
|
163
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
164
|
+
if (object(before) || object(after)) {
|
|
165
|
+
const old = object(before) ? before : {}, next = object(after) ? after : {};
|
|
166
|
+
for (const key of new Set([...Object.keys(old), ...Object.keys(next)])) walk(`${prefix}.${key}`, old[key], next[key]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (JSON.stringify(before ?? null) !== JSON.stringify(after ?? null)) out.add(prefix);
|
|
170
|
+
};
|
|
171
|
+
for (const row of plan.diff) walk(row.path, row.before, row.after);
|
|
172
|
+
return [...out];
|
|
173
|
+
}
|