@north-light/crouter 0.3.309 → 0.3.311
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/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
- package/dist/clients/attach/viewer.js +389 -389
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
- package/dist/commands/api-client.d.ts +1 -0
- package/dist/commands/api-client.js +4 -2
- package/dist/commands/canvas-roster.js +24 -17
- package/dist/commands/dashboard.js +23 -16
- package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
- package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +31 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
- package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
- package/dist/core/canvas/remote-canvas-source.js +58 -6
- package/dist/core/canvas/render-source.d.ts +4 -4
- package/dist/core/canvas/render-source.js +49 -48
- package/dist/core/canvas/source.d.ts +2 -0
- package/dist/core/canvas/source.js +4 -1
- package/dist/core/worktree-mutation-async.js +1 -2
- package/dist/core/worktree.d.ts +0 -1
- package/dist/core/worktree.js +0 -113
- package/dist/daemon/cron-run.js +49 -15
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -76,6 +76,13 @@ function isValidNeighborEntry(x) {
|
|
|
76
76
|
* lifecycle fields local/API-backed row renderers dereference unguarded;
|
|
77
77
|
* the remaining nullable runtime
|
|
78
78
|
* fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
|
|
79
|
+
function isValidRosterEdge(x) {
|
|
80
|
+
return (isPlainObject(x) &&
|
|
81
|
+
typeof x['from_id'] === 'string' &&
|
|
82
|
+
typeof x['to_id'] === 'string' &&
|
|
83
|
+
typeof x['active'] === 'boolean' &&
|
|
84
|
+
typeof x['created'] === 'string');
|
|
85
|
+
}
|
|
79
86
|
function isValidNodeRow(x) {
|
|
80
87
|
if (!isPlainObject(x))
|
|
81
88
|
return false;
|
|
@@ -94,6 +101,7 @@ export class RemoteCanvasSource {
|
|
|
94
101
|
exec;
|
|
95
102
|
nodeCache = new Map();
|
|
96
103
|
rosterCache = new Map();
|
|
104
|
+
topologyCache = null;
|
|
97
105
|
viewCache = new Map();
|
|
98
106
|
constructor(previewEndpoint, relayToken) {
|
|
99
107
|
this.previewEndpoint = previewEndpoint;
|
|
@@ -134,6 +142,46 @@ export class RemoteCanvasSource {
|
|
|
134
142
|
this.nodeCache.set(id, { value: res, at: Date.now() });
|
|
135
143
|
return res;
|
|
136
144
|
}
|
|
145
|
+
topology() {
|
|
146
|
+
if (this.topologyCache !== null && Date.now() - this.topologyCache.at < CACHE_TTL_MS)
|
|
147
|
+
return this.topologyCache.value;
|
|
148
|
+
const request = this.loadTopology();
|
|
149
|
+
this.topologyCache = { value: request, at: Date.now() };
|
|
150
|
+
return request;
|
|
151
|
+
}
|
|
152
|
+
async loadTopology() {
|
|
153
|
+
const response = await this.exec('crtr', ['--json', 'canvas', 'roster']);
|
|
154
|
+
if (!response.ok || (response.exitCode !== undefined && response.exitCode !== 0)) {
|
|
155
|
+
throw new Error('remote canvas topology is unavailable');
|
|
156
|
+
}
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = JSON.parse(response.stdout.trim());
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new Error('remote canvas topology is invalid');
|
|
163
|
+
}
|
|
164
|
+
if (!isPlainObject(raw) || !Array.isArray(raw['edges']) || !raw['edges'].every(isValidRosterEdge)) {
|
|
165
|
+
throw new Error('remote canvas topology is invalid');
|
|
166
|
+
}
|
|
167
|
+
const edges = raw['edges'];
|
|
168
|
+
const topology = { subscriptions: new Map(), subscribers: new Map() };
|
|
169
|
+
for (const edge of edges) {
|
|
170
|
+
const subscription = { node_id: edge.to_id, active: edge.active, created: edge.created };
|
|
171
|
+
const subscriber = { node_id: edge.from_id, active: edge.active, created: edge.created };
|
|
172
|
+
const subscriptions = topology.subscriptions.get(edge.from_id);
|
|
173
|
+
if (subscriptions === undefined)
|
|
174
|
+
topology.subscriptions.set(edge.from_id, [subscription]);
|
|
175
|
+
else
|
|
176
|
+
subscriptions.push(subscription);
|
|
177
|
+
const subscribers = topology.subscribers.get(edge.to_id);
|
|
178
|
+
if (subscribers === undefined)
|
|
179
|
+
topology.subscribers.set(edge.to_id, [subscriber]);
|
|
180
|
+
else
|
|
181
|
+
subscribers.push(subscriber);
|
|
182
|
+
}
|
|
183
|
+
return topology;
|
|
184
|
+
}
|
|
137
185
|
/** The full passthrough object `node inspect show` produces — used by
|
|
138
186
|
* `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
|
|
139
187
|
* same shape a local read would, without re-deriving it. */
|
|
@@ -186,12 +234,10 @@ export class RemoteCanvasSource {
|
|
|
186
234
|
return nodes;
|
|
187
235
|
}
|
|
188
236
|
async subscriptionsOf(id) {
|
|
189
|
-
|
|
190
|
-
return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
|
|
237
|
+
return (await this.topology()).subscriptions.get(id) ?? [];
|
|
191
238
|
}
|
|
192
239
|
async subscribersOf(id) {
|
|
193
|
-
|
|
194
|
-
return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
|
|
240
|
+
return (await this.topology()).subscribers.get(id) ?? [];
|
|
195
241
|
}
|
|
196
242
|
async view(root) {
|
|
197
243
|
const cached = this.viewCache.get(root);
|
|
@@ -214,9 +260,15 @@ export class RemoteCanvasSource {
|
|
|
214
260
|
this.viewCache.set(root, { value: out, at: Date.now() });
|
|
215
261
|
return out;
|
|
216
262
|
}
|
|
263
|
+
async ticketCountsForNodes(nodeIds) {
|
|
264
|
+
// The `canvas attention map` transport's `counts` values mean all pending
|
|
265
|
+
// human tickets (page/review), not just asks.
|
|
266
|
+
if (nodeIds.length === 0)
|
|
267
|
+
return {};
|
|
268
|
+
const res = await this.execJson(['canvas', 'attention', 'map', '--nodes', nodeIds.join(',')]);
|
|
269
|
+
return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
|
|
270
|
+
}
|
|
217
271
|
async ticketCountsForView(root) {
|
|
218
|
-
// The `canvas attention map` transport is unchanged; its `counts` values now
|
|
219
|
-
// mean all pending human tickets (page/review), not just asks.
|
|
220
272
|
const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
|
|
221
273
|
return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
|
|
222
274
|
}
|
|
@@ -13,8 +13,8 @@ export interface NodeTelemetry {
|
|
|
13
13
|
export declare function readNodeTelemetry(nodeId: string): NodeTelemetry;
|
|
14
14
|
/** Format a token count as `Nk` (rounded down to nearest 1 k). */
|
|
15
15
|
export declare function fmtCtx(tokensIn: number | undefined): string;
|
|
16
|
-
export declare function renderTreeFromSource(source: CanvasSource, rootId: string): Promise<string>;
|
|
17
|
-
export declare function renderForestFromSource(source: CanvasSource): Promise<string>;
|
|
16
|
+
export declare function renderTreeFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
|
|
17
|
+
export declare function renderForestFromSource(source: CanvasSource, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
|
|
18
18
|
export interface DashboardRow {
|
|
19
19
|
node_id: string;
|
|
20
20
|
/** Full display label: explicit handle plus generated description. */
|
|
@@ -183,13 +183,13 @@ export declare function dashboardRowFromNodeRow(row: NodeRow, focusedNodeIds: Re
|
|
|
183
183
|
* entirely through the source — the openDb-free sibling of `dashboardRows`.
|
|
184
184
|
* Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
|
|
185
185
|
* API/remote attention projection), never `countTickets` (openDb). */
|
|
186
|
-
export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string): Promise<DashboardRow[]>;
|
|
186
|
+
export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>): Promise<DashboardRow[]>;
|
|
187
187
|
/** CHEAP boot builder over a source — one row per node across the whole canvas,
|
|
188
188
|
* fields that need no per-node meta/session read. Local API callers may supply
|
|
189
189
|
* the node ids from one `listFocuses()` query so `viewed` remains visible without
|
|
190
190
|
* coupling the canvas source to local viewer state. Remote sources ignore that
|
|
191
191
|
* set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
|
|
192
|
-
export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean): Promise<DashboardRow[]>;
|
|
192
|
+
export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean, snapshot?: readonly NodeRow[]): Promise<DashboardRow[]>;
|
|
193
193
|
export declare function enrichRowsFromSource(_source: CanvasSource, rows: DashboardRow[], asks?: Record<string, number>): Promise<void>;
|
|
194
194
|
/** goal (initial-prompt.md) and session parts are local disk reads — suppressed
|
|
195
195
|
* to undefined for a remote source. */
|
|
@@ -60,9 +60,8 @@ export function fmtCtx(tokensIn) {
|
|
|
60
60
|
// LOCAL disk reads (telemetry.json / fault.json), valid for a same-host source
|
|
61
61
|
// but meaningless for a truly remote canvas, so both are suppressed when the
|
|
62
62
|
// source is a `RemoteCanvasSource`.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (node === null)
|
|
63
|
+
function nodeLineFromSource(node, nodeId, indent, connector, remote, tickets) {
|
|
64
|
+
if (node === undefined)
|
|
66
65
|
return `${indent}${connector}? <missing meta: ${nodeId}>`;
|
|
67
66
|
const hanging = remote ? null : hangingFor(node);
|
|
68
67
|
const glyph = resolveNodeVisual(node.status, { hanging, frozen: isFrozenRow(node) }).glyph;
|
|
@@ -72,12 +71,12 @@ async function nodeLineFromSource(source, nodeId, indent, connector, remote, tic
|
|
|
72
71
|
const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
|
|
73
72
|
return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
|
|
74
73
|
}
|
|
75
|
-
async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out, remote, tickets) {
|
|
74
|
+
async function walkTreeFromSource(source, nodes, nodeId, indent, isLast, visited, out, remote, tickets) {
|
|
76
75
|
if (visited.has(nodeId))
|
|
77
76
|
return;
|
|
78
77
|
visited.add(nodeId);
|
|
79
78
|
const connector = isLast ? '└─ ' : '├─ ';
|
|
80
|
-
out.push(
|
|
79
|
+
out.push(nodeLineFromSource(nodes.get(nodeId), nodeId, indent, connector, remote, tickets));
|
|
81
80
|
const childIndent = indent + (isLast ? ' ' : '│ ');
|
|
82
81
|
let children;
|
|
83
82
|
try {
|
|
@@ -95,22 +94,25 @@ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out,
|
|
|
95
94
|
out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
|
|
96
95
|
continue;
|
|
97
96
|
}
|
|
98
|
-
await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
|
|
97
|
+
await walkTreeFromSource(source, nodes, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
|
-
export async function renderTreeFromSource(source, rootId) {
|
|
100
|
+
export async function renderTreeFromSource(source, rootId, snapshot, ticketSnapshot, ticketUnavailable) {
|
|
102
101
|
const remote = source instanceof RemoteCanvasSource;
|
|
103
|
-
const
|
|
104
|
-
|
|
102
|
+
const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
|
|
103
|
+
const node = nodes.get(rootId);
|
|
104
|
+
if (node === undefined)
|
|
105
105
|
return `? <missing node: ${rootId}>`;
|
|
106
|
-
let tickets;
|
|
107
|
-
let ticketsUnavailable = null;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
let tickets = ticketSnapshot;
|
|
107
|
+
let ticketsUnavailable = ticketUnavailable ?? null;
|
|
108
|
+
if (tickets === undefined) {
|
|
109
|
+
try {
|
|
110
|
+
tickets = await source.ticketCountsForView(rootId);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
tickets = {};
|
|
114
|
+
ticketsUnavailable = err instanceof Error ? err.message : String(err);
|
|
115
|
+
}
|
|
114
116
|
}
|
|
115
117
|
const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
|
|
116
118
|
const asks = tickets[rootId] ?? 0;
|
|
@@ -132,14 +134,14 @@ export async function renderTreeFromSource(source, rootId) {
|
|
|
132
134
|
for (let i = 0; i < children.length; i++) {
|
|
133
135
|
const child = children[i];
|
|
134
136
|
const isLast = i === children.length - 1;
|
|
135
|
-
await walkTreeFromSource(source, child.node_id, '', isLast, visited, out, remote, tickets);
|
|
137
|
+
await walkTreeFromSource(source, nodes, child.node_id, '', isLast, visited, out, remote, tickets);
|
|
136
138
|
}
|
|
137
139
|
if (ticketsUnavailable !== null)
|
|
138
140
|
out.push(`Tickets: unavailable (${ticketsUnavailable}).`);
|
|
139
141
|
return out.join('\n');
|
|
140
142
|
}
|
|
141
|
-
export async function renderForestFromSource(source) {
|
|
142
|
-
const all = await source.listNodes();
|
|
143
|
+
export async function renderForestFromSource(source, snapshot, ticketSnapshot, ticketUnavailable) {
|
|
144
|
+
const all = snapshot ?? await source.listNodes();
|
|
143
145
|
if (all.length === 0)
|
|
144
146
|
return '(canvas is empty)';
|
|
145
147
|
const roots = all.filter((n) => n.parent === null && (n.status === 'active' || n.status === 'idle'));
|
|
@@ -147,7 +149,7 @@ export async function renderForestFromSource(source) {
|
|
|
147
149
|
return '(no live roots)';
|
|
148
150
|
const parts = [];
|
|
149
151
|
for (const r of roots) {
|
|
150
|
-
parts.push(await renderTreeFromSource(source, r.node_id));
|
|
152
|
+
parts.push(await renderTreeFromSource(source, r.node_id, all, ticketSnapshot, ticketUnavailable));
|
|
151
153
|
}
|
|
152
154
|
return parts.join('\n\n');
|
|
153
155
|
}
|
|
@@ -563,44 +565,43 @@ export function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
|
|
|
563
565
|
* entirely through the source — the openDb-free sibling of `dashboardRows`.
|
|
564
566
|
* Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
|
|
565
567
|
* API/remote attention projection), never `countTickets` (openDb). */
|
|
566
|
-
export async function dashboardRowsFromSource(source, rootId) {
|
|
568
|
+
export async function dashboardRowsFromSource(source, rootId, snapshot, ticketSnapshot) {
|
|
567
569
|
const remote = source instanceof RemoteCanvasSource;
|
|
568
|
-
const tickets = await source.ticketCountsForView(rootId).catch(() => ({}));
|
|
570
|
+
const tickets = ticketSnapshot ?? await source.ticketCountsForView(rootId).catch(() => ({}));
|
|
571
|
+
const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
|
|
569
572
|
const ids = [rootId, ...(await source.view(rootId))];
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
continue;
|
|
573
|
+
return ids.flatMap((id) => {
|
|
574
|
+
const node = nodes.get(id);
|
|
575
|
+
if (node === undefined)
|
|
576
|
+
return [];
|
|
575
577
|
const tel = remote ? {} : readNodeTelemetry(id);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
return rows;
|
|
578
|
+
return [{
|
|
579
|
+
node_id: id,
|
|
580
|
+
name: fullName(node),
|
|
581
|
+
handle: node.name,
|
|
582
|
+
description: node.description,
|
|
583
|
+
status: node.status,
|
|
584
|
+
frozen_at: node.frozen_at ?? null,
|
|
585
|
+
terminal_reason: node.terminal_reason ?? null,
|
|
586
|
+
kind: node.kind,
|
|
587
|
+
mode: node.mode,
|
|
588
|
+
ctx_tokens: tel.tokens_in ?? 0,
|
|
589
|
+
asks: tickets[id] ?? 0,
|
|
590
|
+
cwd: node.cwd,
|
|
591
|
+
profile_id: node.profile_id,
|
|
592
|
+
created: node.created,
|
|
593
|
+
}];
|
|
594
|
+
});
|
|
594
595
|
}
|
|
595
596
|
/** CHEAP boot builder over a source — one row per node across the whole canvas,
|
|
596
597
|
* fields that need no per-node meta/session read. Local API callers may supply
|
|
597
598
|
* the node ids from one `listFocuses()` query so `viewed` remains visible without
|
|
598
599
|
* coupling the canvas source to local viewer state. Remote sources ignore that
|
|
599
600
|
* set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
|
|
600
|
-
export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false) {
|
|
601
|
+
export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false, snapshot) {
|
|
601
602
|
const remote = source instanceof RemoteCanvasSource;
|
|
602
603
|
const focusedNodeIds = remote ? new Set() : localFocusedNodeIds;
|
|
603
|
-
const rows = await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
|
|
604
|
+
const rows = snapshot ?? await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
|
|
604
605
|
return rows.map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, remote));
|
|
605
606
|
}
|
|
606
607
|
// Lazy enrichment (source-driven) — fold the per-node reads the cheap boot skips
|
|
@@ -9,6 +9,7 @@ export interface CanvasSource {
|
|
|
9
9
|
subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
|
|
10
10
|
subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
|
|
11
11
|
view(root: string): Promise<string[]>;
|
|
12
|
+
ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
|
|
12
13
|
ticketCountsForView(root: string): Promise<Record<string, number>>;
|
|
13
14
|
hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
|
|
14
15
|
}
|
|
@@ -21,6 +22,7 @@ export declare class LocalCanvasSource implements CanvasSource {
|
|
|
21
22
|
subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
|
|
22
23
|
subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
|
|
23
24
|
view(root: string): Promise<string[]>;
|
|
25
|
+
ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
|
|
24
26
|
ticketCountsForView(root: string): Promise<Record<string, number>>;
|
|
25
27
|
hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
|
|
26
28
|
}
|
|
@@ -22,8 +22,11 @@ export class LocalCanvasSource {
|
|
|
22
22
|
async view(root) {
|
|
23
23
|
return view(root);
|
|
24
24
|
}
|
|
25
|
+
async ticketCountsForNodes(nodeIds) {
|
|
26
|
+
return ticketCountsForNodes([...nodeIds]);
|
|
27
|
+
}
|
|
25
28
|
async ticketCountsForView(root) {
|
|
26
|
-
return ticketCountsForNodes([root, ...view(root)]);
|
|
29
|
+
return this.ticketCountsForNodes([root, ...view(root)]);
|
|
27
30
|
}
|
|
28
31
|
async hasActiveLiveSubscription(nodeId) {
|
|
29
32
|
return hasActiveLiveSubscription(nodeId);
|
|
@@ -247,9 +247,8 @@ export async function abandonManagedWorktreeAsync(nodeId, by) {
|
|
|
247
247
|
cleanBase = base;
|
|
248
248
|
}
|
|
249
249
|
catch (error) {
|
|
250
|
-
if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch'
|
|
250
|
+
if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch')
|
|
251
251
|
throw error;
|
|
252
|
-
cleanBase = current.base_sha;
|
|
253
252
|
}
|
|
254
253
|
const at = new Date().toISOString();
|
|
255
254
|
const status = await runGit(current.path, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -76,7 +76,6 @@ export interface AbandonManagedWorktreeResult {
|
|
|
76
76
|
worktree_path: string;
|
|
77
77
|
branch_deleted: boolean;
|
|
78
78
|
}
|
|
79
|
-
export declare function abandonManagedWorktree(nodeId: string, by: string): AbandonManagedWorktreeResult;
|
|
80
79
|
/** Recovery guidance for a vanished checkout whose refs do not prove that its
|
|
81
80
|
* work is delivered. This deliberately never recommends `worktree close`:
|
|
82
81
|
* that command cannot operate on the absent checkout. */
|
package/dist/core/worktree.js
CHANGED
|
@@ -3,7 +3,6 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { gitAsync, gitSync } from './git.js';
|
|
5
5
|
import { crtrHome } from './canvas/paths.js';
|
|
6
|
-
import { isPidAlive } from './canvas/pid.js';
|
|
7
6
|
import { getNode, updateNode } from './canvas/index.js';
|
|
8
7
|
import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
|
|
9
8
|
import { withExclusiveDirectoryLock } from './exclusive-lock.js';
|
|
@@ -658,118 +657,6 @@ export function closeManagedWorktree(nodeId) {
|
|
|
658
657
|
// the child checkout instead of the parent that created it.
|
|
659
658
|
return withRepositoryWorktreeLock(existsSync(wt.path) ? wt.path : wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
|
|
660
659
|
}
|
|
661
|
-
const ABANDON_AUTHOR_NAME = 'crtr-abandon';
|
|
662
|
-
const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
|
|
663
|
-
const ABANDON_SUBJECT = 'crtr-abandon';
|
|
664
|
-
function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by, branch = wt.branch) {
|
|
665
|
-
updateNode(nodeId, {
|
|
666
|
-
cwd: wt.repo_root,
|
|
667
|
-
managed_worktree: {
|
|
668
|
-
...wt,
|
|
669
|
-
state: 'abandoned',
|
|
670
|
-
abandoned: { branch, tip_sha: tipSha, at, by },
|
|
671
|
-
},
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
/** The disposal path for `abandon` when the recorded `crtr/*` branch is gone:
|
|
675
|
-
* ordinary PR work moved this checkout onto a real branch. Abandon never
|
|
676
|
-
* lands that branch and — unlike the ordinary path above — never invents a
|
|
677
|
-
* commit on it: a synthetic `crtr-abandon` marker on someone's real PR
|
|
678
|
-
* branch is not a trade this code may make on their behalf. So this
|
|
679
|
-
* requires the checkout already clean and not mid-operation, then removes
|
|
680
|
-
* only the checkout and preserves the actual branch and its commits exactly
|
|
681
|
-
* as they stand. The caller already confirmed the node is not running. */
|
|
682
|
-
function abandonBranchMovedWorktree(nodeId, wt, actualBranch, by) {
|
|
683
|
-
if (isRebaseInProgress(wt.path)) {
|
|
684
|
-
throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree abandon\`.`);
|
|
685
|
-
}
|
|
686
|
-
const dirty = statusPorcelain(wt.path);
|
|
687
|
-
if (dirty !== '') {
|
|
688
|
-
throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `The recorded branch ${wt.branch} no longer exists; its checkout is now on ${actualBranch} with uncommitted changes crtr will not commit on your behalf. Commit or stash them in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
|
|
689
|
-
}
|
|
690
|
-
if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true) {
|
|
691
|
-
throw new WorktreeError('wrong_branch', `managed worktree's recorded branch ${wt.branch} no longer exists, and its checkout at ${wt.path} is not registered to ${actualBranch} either`, `Inspect ${wt.path} and its git worktree registration, then ask the user with \`crtr human send\` if you are unsure how to proceed.`);
|
|
692
|
-
}
|
|
693
|
-
const actualSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the worktree manually, then retry.');
|
|
694
|
-
// Recheck identity immediately before the destructive step.
|
|
695
|
-
if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true || currentBranch(wt.path) !== actualBranch) {
|
|
696
|
-
throw new WorktreeError('wrong_branch', `managed worktree at ${wt.path} changed while abandoning`, `Inspect ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
|
|
697
|
-
}
|
|
698
|
-
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
699
|
-
const at = new Date().toISOString();
|
|
700
|
-
persistAbandonedWorktreeState(nodeId, wt, actualSha, at, by, actualBranch);
|
|
701
|
-
return { node_id: nodeId, branch: actualBranch, tip_sha: actualSha, worktree_path: wt.path, branch_deleted: false };
|
|
702
|
-
}
|
|
703
|
-
function createAbandonMarker(wt, parentSha, nodeId, at) {
|
|
704
|
-
const treeSha = runGit(wt.repo_root, ['rev-parse', `${parentSha}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
|
|
705
|
-
const markerSha = runGit(wt.repo_root, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit-tree', treeSha, '-p', parentSha, '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the branch manually, then retry.');
|
|
706
|
-
runGit(wt.repo_root, ['update-ref', `refs/heads/${wt.branch}`, markerSha, parentSha], 'abandon_marker_failed', 'The branch changed while abandon was starting; inspect the worktree, then retry.');
|
|
707
|
-
return markerSha;
|
|
708
|
-
}
|
|
709
|
-
function abandonManagedWorktreeLocked(nodeId, by) {
|
|
710
|
-
const node = getNode(nodeId);
|
|
711
|
-
if (node === null) {
|
|
712
|
-
throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Pass a real node id.');
|
|
713
|
-
}
|
|
714
|
-
const wt = openManagedWorktreeForNode(nodeId);
|
|
715
|
-
if (wt === null) {
|
|
716
|
-
const { message, next } = noManagedWorktreeMessage(nodeId, 'abandon');
|
|
717
|
-
throw new WorktreeError('no_managed_worktree', message, next);
|
|
718
|
-
}
|
|
719
|
-
if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid)) {
|
|
720
|
-
throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
|
|
721
|
-
}
|
|
722
|
-
if (!existsSync(wt.path)) {
|
|
723
|
-
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
|
|
724
|
-
}
|
|
725
|
-
const actualBranch = currentBranch(wt.path);
|
|
726
|
-
if (actualBranch !== wt.branch) {
|
|
727
|
-
const recordedRefExists = recordedBranchExists(wt.path, wt.branch);
|
|
728
|
-
if (recordedRefExists || actualBranch === 'HEAD') {
|
|
729
|
-
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}${actualBranch === 'HEAD' ? ', found detached HEAD' : ''})`, recordedRefExists
|
|
730
|
-
? `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`
|
|
731
|
-
: `The recorded branch ${wt.branch} no longer exists, and ${wt.path} is in detached HEAD, so there is no branch to preserve. Check out or create a branch there, then rerun \`crtr node worktree abandon\`.`);
|
|
732
|
-
}
|
|
733
|
-
// The recorded crtr/* branch is gone: ordinary PR work moved this checkout
|
|
734
|
-
// onto a real branch.
|
|
735
|
-
return abandonBranchMovedWorktree(nodeId, wt, actualBranch, by);
|
|
736
|
-
}
|
|
737
|
-
const at = new Date().toISOString();
|
|
738
|
-
const clean = proveCleanEmptyManagedWorktree(wt);
|
|
739
|
-
if (clean !== null && clean.branchSha === wt.base_sha) {
|
|
740
|
-
const markerSha = createAbandonMarker(wt, clean.branchSha, nodeId, at);
|
|
741
|
-
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
742
|
-
const deleted = bestEffortDeleteBranchWithContainmentProof(wt, markerSha, `refs/heads/${wt.base_ref}`, clean.baseSha);
|
|
743
|
-
if (deleted.branchDeleted) {
|
|
744
|
-
persistClosedWorktreeState(nodeId, wt, 'complete');
|
|
745
|
-
return { node_id: nodeId, branch: wt.branch, tip_sha: markerSha, worktree_path: wt.path, branch_deleted: true };
|
|
746
|
-
}
|
|
747
|
-
const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
748
|
-
persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
|
|
749
|
-
return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
|
|
750
|
-
}
|
|
751
|
-
// A clean branch with committed work already has its complete history. The
|
|
752
|
-
// abandon record supplies the disposition; an empty provenance commit would
|
|
753
|
-
// only move the local branch away from an already-pushed tip.
|
|
754
|
-
if (!isRebaseInProgress(wt.path) && statusPorcelain(wt.path) === '') {
|
|
755
|
-
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
756
|
-
const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
757
|
-
persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
|
|
758
|
-
return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
|
|
759
|
-
}
|
|
760
|
-
runGit(wt.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
|
|
761
|
-
runGit(wt.path, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit', '--allow-empty', '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the worktree manually, then retry.');
|
|
762
|
-
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
763
|
-
const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
764
|
-
persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
|
|
765
|
-
return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
|
|
766
|
-
}
|
|
767
|
-
export function abandonManagedWorktree(nodeId, by) {
|
|
768
|
-
const wt = openManagedWorktreeForNode(nodeId);
|
|
769
|
-
if (wt === null)
|
|
770
|
-
return abandonManagedWorktreeLocked(nodeId, by);
|
|
771
|
-
return withRepositoryWorktreeLock(wt.repo_root, () => abandonManagedWorktreeLocked(nodeId, by));
|
|
772
|
-
}
|
|
773
660
|
// Push-final auto-drop of a clean, zero-commit managed worktree
|
|
774
661
|
/** Delete a source branch only while the ref that proved it already delivered
|
|
775
662
|
* is still at the exact inspected SHA. `containingRef` can be the recorded
|
package/dist/daemon/cron-run.js
CHANGED
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
// promises for graceful-teardown drain but never blocks on a slow run.
|
|
36
36
|
import { spawn } from 'node:child_process';
|
|
37
37
|
import { createHash, randomUUID } from 'node:crypto';
|
|
38
|
-
import { CRON_KILL_GRACE_MS, CRON_OUTPUT_HEAD_BYTES, EMPTY_STDOUT_SHA256, newHeadCapture, } from './cron/capture.js';
|
|
38
|
+
import { CRON_DEATH_POLL_MS, CRON_KILL_GRACE_MS, CRON_OUTPUT_HEAD_BYTES, EMPTY_STDOUT_SHA256, newHeadCapture, } from './cron/capture.js';
|
|
39
39
|
import { clearLiveRun, cronDaemonInstanceId, liveRunFor, registerLiveRun } from './cron/live-runs.js';
|
|
40
40
|
import { buildArmedRunContext } from './cron/armed-context.js';
|
|
41
41
|
import { decideHeldSettle, EXIT_HELD, HELD_DELIVERED } from './cron/held.js';
|
|
42
42
|
import { settleRun } from './cron/settle.js';
|
|
43
43
|
import { applyOverlapPolicies, expireDueCrons, } from './cron/passes.js';
|
|
44
44
|
import { dueClockCrons, acquireCronRunLease, advanceCron, setCronState, } from '../core/canvas/crons.js';
|
|
45
|
-
import {
|
|
45
|
+
import { captureTeardownSnapshot, isAnyPidAlive, killProcessTreePids } from '../core/canvas/pid.js';
|
|
46
46
|
import { emitEvent } from '../core/events/emit.js';
|
|
47
47
|
import { nextSlotAfter } from '../core/wake.js';
|
|
48
48
|
import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
|
|
@@ -239,6 +239,9 @@ export function executeCron(c, opts) {
|
|
|
239
239
|
let settled = false;
|
|
240
240
|
let timer;
|
|
241
241
|
let killTimer;
|
|
242
|
+
let deathTimer;
|
|
243
|
+
let closeCode;
|
|
244
|
+
let killed = false;
|
|
242
245
|
let timedOut = false;
|
|
243
246
|
let leaseLost = false;
|
|
244
247
|
let processError = null;
|
|
@@ -259,19 +262,8 @@ export function executeCron(c, opts) {
|
|
|
259
262
|
child.stderr?.on('data', (chunk) => {
|
|
260
263
|
stderr.take(chunk);
|
|
261
264
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (killTimer === undefined) {
|
|
265
|
-
killTimer = setTimeout(() => killProcessGroup(pid, 'SIGKILL'), CRON_KILL_GRACE_MS);
|
|
266
|
-
killTimer.unref?.();
|
|
267
|
-
}
|
|
268
|
-
};
|
|
269
|
-
// A signal, not settlement: 'close' always follows 'error'.
|
|
270
|
-
child.on('error', (err) => {
|
|
271
|
-
if (processError == null)
|
|
272
|
-
processError = err;
|
|
273
|
-
});
|
|
274
|
-
child.on('close', (code) => {
|
|
265
|
+
let teardown;
|
|
266
|
+
const settleClosedChild = (code) => {
|
|
275
267
|
if (settled)
|
|
276
268
|
return;
|
|
277
269
|
settled = true;
|
|
@@ -279,6 +271,8 @@ export function executeCron(c, opts) {
|
|
|
279
271
|
clearTimeout(timer);
|
|
280
272
|
if (killTimer !== undefined)
|
|
281
273
|
clearTimeout(killTimer);
|
|
274
|
+
if (deathTimer !== undefined)
|
|
275
|
+
clearTimeout(deathTimer);
|
|
282
276
|
const stdoutHead = stdout.decode();
|
|
283
277
|
const stderrHead = stderr.decode();
|
|
284
278
|
const live = liveRunFor(c.cron_id);
|
|
@@ -306,6 +300,46 @@ export function executeCron(c, opts) {
|
|
|
306
300
|
wasReplaced,
|
|
307
301
|
wasSelfCanceled,
|
|
308
302
|
}).then(resolve, reject);
|
|
303
|
+
};
|
|
304
|
+
const awaitTeardownDeath = () => {
|
|
305
|
+
if (teardown === undefined || !isAnyPidAlive(teardown.tree)) {
|
|
306
|
+
if (closeCode !== undefined)
|
|
307
|
+
settleClosedChild(closeCode);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
deathTimer = setTimeout(awaitTeardownDeath, CRON_DEATH_POLL_MS);
|
|
311
|
+
};
|
|
312
|
+
const killWithEscalation = (pid) => {
|
|
313
|
+
teardown ??= captureTeardownSnapshot(pid, null);
|
|
314
|
+
killProcessTreePids(teardown.tree, 'SIGTERM', teardown.identities);
|
|
315
|
+
if (killTimer === undefined) {
|
|
316
|
+
killTimer = setTimeout(() => {
|
|
317
|
+
if (isAnyPidAlive(teardown.tree))
|
|
318
|
+
killProcessTreePids(teardown.tree, 'SIGKILL', teardown.identities);
|
|
319
|
+
killed = true;
|
|
320
|
+
if (closeCode !== undefined)
|
|
321
|
+
awaitTeardownDeath();
|
|
322
|
+
}, CRON_KILL_GRACE_MS);
|
|
323
|
+
killTimer.unref?.();
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
// A signal, not settlement: 'close' always follows 'error'.
|
|
327
|
+
child.on('error', (err) => {
|
|
328
|
+
if (processError == null)
|
|
329
|
+
processError = err;
|
|
330
|
+
});
|
|
331
|
+
child.on('close', (code) => {
|
|
332
|
+
if (settled)
|
|
333
|
+
return;
|
|
334
|
+
closeCode = code;
|
|
335
|
+
if (timer !== undefined)
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
if (teardown !== undefined && isAnyPidAlive(teardown.tree)) {
|
|
338
|
+
if (killed)
|
|
339
|
+
awaitTeardownDeath();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
settleClosedChild(code);
|
|
309
343
|
});
|
|
310
344
|
if (child.pid == null ||
|
|
311
345
|
!acquireCronRunLease(c.cron_id, cronDaemonInstanceId, child.pid, startedAtIso, outOfBand)) {
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.311",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.311",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,17 +5206,17 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.311",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.311",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.311",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|