@north-light/crouter 0.3.275 → 0.3.277
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-memory/02-turn-lifecycle/01-terminal.md +1 -1
- package/dist/clients/attach/viewer.js +1 -1
- package/dist/commands/cron.js +3 -3
- package/dist/commands/node/create.js +3 -1
- package/dist/commands/node-worktree.js +7 -7
- package/dist/commands/pkg/plugin-manage.js +19 -8
- package/dist/commands/push.js +1 -1
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +12 -10
- package/dist/core/__tests__/integration/command-plugins.test.js +6 -6
- package/dist/core/__tests__/integration/worktree-land.test.js +7 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +3 -16
- package/dist/core/canvas/types.d.ts +18 -0
- package/dist/core/command-manifests/manifest.d.ts +4 -2
- package/dist/core/command-plugins/bundle.d.ts +7 -0
- package/dist/core/command-plugins/bundle.js +18 -4
- package/dist/core/command-plugins/help-addenda.d.ts +12 -3
- package/dist/core/command-plugins/help-addenda.js +17 -4
- package/dist/core/command.js +2 -2
- package/dist/core/git.d.ts +5 -1
- package/dist/core/git.js +18 -4
- package/dist/core/runtime/spawn.d.ts +2 -0
- package/dist/core/runtime/spawn.js +1 -1
- package/dist/core/worktree-sweep.js +28 -5
- package/dist/core/worktree.d.ts +1 -12
- package/dist/core/worktree.js +222 -10
- package/dist/daemon/api/handlers/crons.js +5 -4
- package/dist/daemon/api/handlers/messages.js +5 -0
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/daemon/api/handlers/reports.js +11 -7
- package/dist/daemon/reconcilers/live-obligation.d.ts +3 -2
- package/dist/daemon/reconcilers/live-obligation.js +4 -0
- package/dist/types.d.ts +3 -3
- package/dist/types.js +2 -2
- package/package.json +3 -1
- package/runtime.lock.json +2 -2
|
@@ -821,9 +821,9 @@ describe('typed validation issues', () => {
|
|
|
821
821
|
describe('plugin help addenda', () => {
|
|
822
822
|
const gate = (paths) => ({
|
|
823
823
|
reservedCoreNames: RESERVED,
|
|
824
|
-
coreCommandPaths: new Set(paths),
|
|
824
|
+
coreCommandPaths: () => Promise.resolve(new Set(paths)),
|
|
825
825
|
});
|
|
826
|
-
test('a helpAddenda entry survives validation and is collected attributed to its plugin', () => {
|
|
826
|
+
test('a helpAddenda entry survives validation and is collected attributed to its plugin', async () => {
|
|
827
827
|
const m = commandsJson();
|
|
828
828
|
m['helpAddenda'] = { cron: 'Gate on the device: exit 75 parks the occurrence.' };
|
|
829
829
|
installPlugin(userRoot, 'northlight-fixture', { manifest: m });
|
|
@@ -833,12 +833,12 @@ describe('plugin help addenda', () => {
|
|
|
833
833
|
assert.equal(v.issues.length, 0, JSON.stringify(v.issues));
|
|
834
834
|
assert.deepEqual(v.manifest?.helpAddenda, { cron: 'Gate on the device: exit 75 parks the occurrence.' });
|
|
835
835
|
// The help-render lookup: attributed, and empty for an unaddended path.
|
|
836
|
-
assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'node']), emptyStart), [
|
|
836
|
+
assert.deepEqual(await collectHelpAddenda('cron', gate(['cron', 'node']), emptyStart), [
|
|
837
837
|
{ plugin: 'northlight-fixture', text: 'Gate on the device: exit 75 parks the occurrence.' },
|
|
838
838
|
]);
|
|
839
|
-
assert.deepEqual(collectHelpAddenda('node', gate(['cron', 'node']), emptyStart), []);
|
|
839
|
+
assert.deepEqual(await collectHelpAddenda('node', gate(['cron', 'node']), emptyStart), []);
|
|
840
840
|
});
|
|
841
|
-
test('a key naming no core command path fails the strict gate and renders nothing, even beside a valid key', () => {
|
|
841
|
+
test('a key naming no core command path fails the strict gate and renders nothing, even beside a valid key', async () => {
|
|
842
842
|
const m = commandsJson();
|
|
843
843
|
m['helpAddenda'] = {
|
|
844
844
|
cron: 'a valid target beside the typo',
|
|
@@ -860,7 +860,7 @@ describe('plugin help addenda', () => {
|
|
|
860
860
|
// The render lookup reproduces the GATE verdict, not the compose one: the
|
|
861
861
|
// rejected map contributes nothing — including its valid key — so render
|
|
862
862
|
// can never show what the install report said was rejected.
|
|
863
|
-
assert.deepEqual(collectHelpAddenda('cron', gate(['cron', 'cron add']), emptyStart), []);
|
|
863
|
+
assert.deepEqual(await collectHelpAddenda('cron', gate(['cron', 'cron add']), emptyStart), []);
|
|
864
864
|
});
|
|
865
865
|
test('an installed addendum renders through the real help path as an attributed block', async () => {
|
|
866
866
|
const m = commandsJson();
|
|
@@ -440,8 +440,13 @@ test('close concludes a clean child after parent-first closure removes its check
|
|
|
440
440
|
run(['commit', '-m', 'init'], repo);
|
|
441
441
|
const parent = createManagedWorktree(repo, 'wt-parent-first-parent');
|
|
442
442
|
createNode(node('wt-parent-first-parent', { cwd: parent.path, managed_worktree: parent }));
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
// Creation now refuses a managed worktree as its base (`managed_worktree_base`),
|
|
444
|
+
// so the child is cut from the real repository against the parent's BRANCH, and
|
|
445
|
+
// its record carries the parent checkout as `repo_root` — the exact legacy shape
|
|
446
|
+
// this case is about, still on disk for every record created before that guard.
|
|
447
|
+
const child = createManagedWorktree(repo, 'wt-parent-first-child', parent.branch);
|
|
448
|
+
const childRecord = { ...child, repo_root: parent.path };
|
|
449
|
+
createNode(node('wt-parent-first-child', { cwd: child.path, managed_worktree: childRecord }));
|
|
445
450
|
assert.equal(gitSync(['rev-parse', 'HEAD'], child.path).stdout.trim(), child.base_sha, 'the child must be a clean zero-commit checkout at its recorded base');
|
|
446
451
|
closeManagedWorktree('wt-parent-first-parent');
|
|
447
452
|
run(['worktree', 'remove', parent.path], repo);
|
|
@@ -9,7 +9,7 @@ import { test, before, after, afterEach } from 'node:test';
|
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
-
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
12
|
+
import { createHeadlessHarness, readLines } from '../helpers/harness.js';
|
|
13
13
|
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
14
14
|
import { defaultModelLaddersConfig } from '../../../types.js';
|
|
15
15
|
import { admitProviderRetryEpisode, readFault, readProviderRetryEpisode } from '../../runtime/fault.js';
|
|
@@ -63,21 +63,8 @@ function overflowOutcome(id) {
|
|
|
63
63
|
// re-drive is a fresh session.prompt → emitTurn, which fires one 'agent_start';
|
|
64
64
|
// counting them is the observable proof of how many times the turn was re-driven.
|
|
65
65
|
function agentStartCount(id) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.split('\n')
|
|
69
|
-
.filter((l) => {
|
|
70
|
-
try {
|
|
71
|
-
return JSON.parse(l).event === 'agent_start';
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
}).length;
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return 0;
|
|
80
|
-
}
|
|
66
|
+
return readLines(join(h.home, 'nodes', id, 'fake-pi.events.jsonl'))
|
|
67
|
+
.filter((line) => JSON.parse(line).event === 'agent_start').length;
|
|
81
68
|
}
|
|
82
69
|
async function crashAndRespawn(id, boots) {
|
|
83
70
|
const pid = h.node(id)?.pi_pid;
|
|
@@ -106,6 +106,24 @@ export interface ManagedWorktree {
|
|
|
106
106
|
blocked: string;
|
|
107
107
|
};
|
|
108
108
|
sweep?: ManagedWorktreeSweep;
|
|
109
|
+
/** Recorded ONLY by an explicit branch-moved `close` when the checkout's
|
|
110
|
+
* actual branch has drifted from `branch` (its recorded `crtr/*` ref is
|
|
111
|
+
* gone — ordinary PR work moved it there) but that branch's content was
|
|
112
|
+
* proven, immediately before this was written, already contained in the
|
|
113
|
+
* recorded base or its upstream. Authorizes ONLY the daemon sweep's later
|
|
114
|
+
* checkout removal for this EXACT path + git common dir + observed branch
|
|
115
|
+
* + observed SHA — the sweep re-verifies all four before acting and NEVER
|
|
116
|
+
* deletes `observed_branch`, since it is not a `crtr/*` branch crouter
|
|
117
|
+
* created. A mismatch on re-verification invalidates the authorization; a
|
|
118
|
+
* fresh explicit close must re-establish it. Absent for every ordinary
|
|
119
|
+
* managed worktree, and for `abandon`'s branch-moved path (which disposes
|
|
120
|
+
* immediately instead of deferring, so it needs no persisted grant). */
|
|
121
|
+
disposal?: {
|
|
122
|
+
observed_branch: string;
|
|
123
|
+
observed_sha: string;
|
|
124
|
+
common_dir: string;
|
|
125
|
+
authorized: string;
|
|
126
|
+
};
|
|
109
127
|
}
|
|
110
128
|
/** Immutable companion provenance. The origin session/leaf/model coordinates
|
|
111
129
|
* remain canonical in the review row rather than being duplicated into node
|
|
@@ -29,8 +29,10 @@ export declare function validateCommandManifest(raw: unknown, options: {
|
|
|
29
29
|
* (install, bundle parse, doctor/inspect reports) so a helpAddenda key
|
|
30
30
|
* naming no core command fails there — and by the help-render lookup
|
|
31
31
|
* (`collectHelpAddenda`), which reproduces the gate's verdict so render
|
|
32
|
-
* and ingress report can never disagree. Omitted
|
|
33
|
-
*
|
|
32
|
+
* and ingress report can never disagree. Omitted on the tolerant compose
|
|
33
|
+
* path, where mounting ignores addenda entirely, and on that lookup's
|
|
34
|
+
* claimant pass, which only needs to know whether any addendum targets the
|
|
35
|
+
* rendered path at all. */
|
|
34
36
|
coreCommandPaths?: ReadonlySet<string>;
|
|
35
37
|
/** A repository fragment attaches roots below an existing extensible branch. */
|
|
36
38
|
extensionFragment?: boolean;
|
|
@@ -32,6 +32,13 @@ export interface ValidatedBundle {
|
|
|
32
32
|
path: string;
|
|
33
33
|
bytes: Uint8Array;
|
|
34
34
|
}>;
|
|
35
|
+
hooks?: {
|
|
36
|
+
manifest: Uint8Array;
|
|
37
|
+
files: ReadonlyArray<{
|
|
38
|
+
path: string;
|
|
39
|
+
bytes: Uint8Array;
|
|
40
|
+
}>;
|
|
41
|
+
};
|
|
35
42
|
}
|
|
36
43
|
export interface PluginBundleValidation {
|
|
37
44
|
bundle?: ValidatedBundle;
|
|
@@ -38,13 +38,13 @@ function validateMemberName(path, type) {
|
|
|
38
38
|
}
|
|
39
39
|
function validateMemberShape(path, type) {
|
|
40
40
|
if (type === 'file') {
|
|
41
|
-
if (path === 'bundle.json' || path === 'commands.json' || (path.startsWith('memory/') && path.endsWith('.md')))
|
|
41
|
+
if (path === 'bundle.json' || path === 'commands.json' || path === 'hooks.json' || (path.startsWith('memory/') && path.endsWith('.md')) || path.startsWith('hooks/'))
|
|
42
42
|
return undefined;
|
|
43
|
-
return bundleInvalid('bundle contains an unsupported file member', path, 'bundle.json, commands.json,
|
|
43
|
+
return bundleInvalid('bundle contains an unsupported file member', path, 'bundle.json, commands.json, hooks.json, memory/**/*.md, or hooks/**', 'Remove unsupported members from the archive.', path);
|
|
44
44
|
}
|
|
45
|
-
if (path === 'memory' || path.startsWith('memory/'))
|
|
45
|
+
if (path === 'memory' || path.startsWith('memory/') || path === 'hooks' || path.startsWith('hooks/'))
|
|
46
46
|
return undefined;
|
|
47
|
-
return bundleInvalid('bundle contains an unsupported directory member', path, 'memory or a directory below
|
|
47
|
+
return bundleInvalid('bundle contains an unsupported directory member', path, 'memory or hooks, or a directory below either subtree', 'Remove unsupported directory members from the archive.', path);
|
|
48
48
|
}
|
|
49
49
|
/** A member path as it will exist on disk: directories carry no trailing slash. */
|
|
50
50
|
function materializedPath(member) {
|
|
@@ -221,6 +221,14 @@ export async function validatePluginBundle(archive, options) {
|
|
|
221
221
|
issues: [bundleInvalid('bundle must contain exactly one bundle.json and one commands.json regular file', `bundle.json=${bundle !== undefined}, commands.json=${commands !== undefined}`, 'one regular bundle.json and one regular commands.json member', 'Add the required metadata members to the archive.')],
|
|
222
222
|
};
|
|
223
223
|
}
|
|
224
|
+
const hookManifest = parsed.members.find((member) => member.path === 'hooks.json' && member.type === 'file');
|
|
225
|
+
const hookFiles = parsed.members.filter((member) => member.type === 'file' && member.path.startsWith('hooks/'));
|
|
226
|
+
const carriesHooks = hookManifest !== undefined || parsed.members.some((member) => member.path === 'hooks' || member.path.startsWith('hooks/'));
|
|
227
|
+
if (carriesHooks && (hookManifest === undefined || !hookFiles.some((member) => member.path === 'hooks/dispatch'))) {
|
|
228
|
+
return {
|
|
229
|
+
issues: [bundleInvalid('bundle hook payload must contain both hooks.json and hooks/dispatch regular files', `hooks.json=${hookManifest !== undefined}, hooks/dispatch=${hookFiles.some((member) => member.path === 'hooks/dispatch')}`, 'both hooks.json and hooks/dispatch regular files', 'Add both required hook members or remove the hook payload.')],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
224
232
|
const metadata = parseBundleMetadata(bundle.bytes);
|
|
225
233
|
if (metadata.issue !== undefined)
|
|
226
234
|
return { issues: [metadata.issue] };
|
|
@@ -241,6 +249,12 @@ export async function validatePluginBundle(archive, options) {
|
|
|
241
249
|
memory: parsed.members
|
|
242
250
|
.filter((member) => member.type === 'file' && member.path.startsWith('memory/'))
|
|
243
251
|
.map((member) => ({ path: member.path, bytes: member.bytes })),
|
|
252
|
+
...(hookManifest !== undefined ? {
|
|
253
|
+
hooks: {
|
|
254
|
+
manifest: hookManifest.bytes,
|
|
255
|
+
files: hookFiles.map((member) => ({ path: member.path, bytes: member.bytes })),
|
|
256
|
+
},
|
|
257
|
+
} : {}),
|
|
244
258
|
},
|
|
245
259
|
issues: [],
|
|
246
260
|
};
|
|
@@ -5,8 +5,17 @@ export interface HelpAddendum {
|
|
|
5
5
|
/** Collect every enabled plugin's addendum targeting `commandPath` (the
|
|
6
6
|
* space-joined walked path, e.g. "cron add"), in plugin-name order. `gate`
|
|
7
7
|
* carries the strict-validation inputs the install/bundle gates use; the
|
|
8
|
-
* help renderer already holds both (build-root exports them).
|
|
8
|
+
* help renderer already holds both (build-root exports them).
|
|
9
|
+
*
|
|
10
|
+
* `coreCommandPaths` arrives as a thunk because producing that set walks EVERY
|
|
11
|
+
* core subtree — the whole command tree loaded on a path that renders one
|
|
12
|
+
* leaf's help. So the claimant pass below runs the SAME validator without it:
|
|
13
|
+
* the core path set can only reject an addendum, never introduce one, so a
|
|
14
|
+
* commandPath no manifest claims tolerantly claims none under the gate either,
|
|
15
|
+
* and the expensive input is never forced. Claimants then re-validate strictly,
|
|
16
|
+
* reproducing the gate's exact verdict — a manifest the gate rejects renders
|
|
17
|
+
* nothing, including its valid keys. */
|
|
9
18
|
export declare function collectHelpAddenda(commandPath: string, gate: {
|
|
10
19
|
reservedCoreNames: ReadonlySet<string>;
|
|
11
|
-
coreCommandPaths: ReadonlySet<string
|
|
12
|
-
}, startDir?: string, profileId?: string | null): HelpAddendum[]
|
|
20
|
+
coreCommandPaths: () => Promise<ReadonlySet<string>>;
|
|
21
|
+
}, startDir?: string, profileId?: string | null): Promise<HelpAddendum[]>;
|
|
@@ -15,13 +15,26 @@ import { effectiveCommandPlugins, validatePluginCommands } from './discovery.js'
|
|
|
15
15
|
/** Collect every enabled plugin's addendum targeting `commandPath` (the
|
|
16
16
|
* space-joined walked path, e.g. "cron add"), in plugin-name order. `gate`
|
|
17
17
|
* carries the strict-validation inputs the install/bundle gates use; the
|
|
18
|
-
* help renderer already holds both (build-root exports them).
|
|
19
|
-
|
|
18
|
+
* help renderer already holds both (build-root exports them).
|
|
19
|
+
*
|
|
20
|
+
* `coreCommandPaths` arrives as a thunk because producing that set walks EVERY
|
|
21
|
+
* core subtree — the whole command tree loaded on a path that renders one
|
|
22
|
+
* leaf's help. So the claimant pass below runs the SAME validator without it:
|
|
23
|
+
* the core path set can only reject an addendum, never introduce one, so a
|
|
24
|
+
* commandPath no manifest claims tolerantly claims none under the gate either,
|
|
25
|
+
* and the expensive input is never forced. Claimants then re-validate strictly,
|
|
26
|
+
* reproducing the gate's exact verdict — a manifest the gate rejects renders
|
|
27
|
+
* nothing, including its valid keys. */
|
|
28
|
+
export async function collectHelpAddenda(commandPath, gate, startDir, profileId) {
|
|
20
29
|
if (commandPath.length === 0)
|
|
21
30
|
return [];
|
|
31
|
+
const claimants = effectiveCommandPlugins(startDir, profileId).filter((plugin) => validatePluginCommands(plugin, gate.reservedCoreNames).manifest?.helpAddenda?.[commandPath] !== undefined);
|
|
32
|
+
if (claimants.length === 0)
|
|
33
|
+
return [];
|
|
34
|
+
const coreCommandPaths = await gate.coreCommandPaths();
|
|
22
35
|
const out = [];
|
|
23
|
-
for (const plugin of
|
|
24
|
-
const text = validatePluginCommands(plugin, gate.reservedCoreNames,
|
|
36
|
+
for (const plugin of claimants) {
|
|
37
|
+
const text = validatePluginCommands(plugin, gate.reservedCoreNames, coreCommandPaths).manifest?.helpAddenda?.[commandPath];
|
|
25
38
|
if (text !== undefined)
|
|
26
39
|
out.push({ plugin: plugin.name, text });
|
|
27
40
|
}
|
package/dist/core/command.js
CHANGED
|
@@ -209,9 +209,9 @@ async function renderNodeWithAddenda(node, path, focusedFlag) {
|
|
|
209
209
|
import('./command-plugins/help-addenda.js'),
|
|
210
210
|
import('../build-root.js'),
|
|
211
211
|
]);
|
|
212
|
-
const addenda = collectHelpAddenda(path.join(' '), {
|
|
212
|
+
const addenda = await collectHelpAddenda(path.join(' '), {
|
|
213
213
|
reservedCoreNames: new Set(SUBTREE_NAMES),
|
|
214
|
-
coreCommandPaths
|
|
214
|
+
coreCommandPaths,
|
|
215
215
|
});
|
|
216
216
|
return body + addenda.map((a) => `\n\n<plugin-help plugin="${a.plugin}">\n${a.text}\n</plugin-help>`).join('');
|
|
217
217
|
}
|
package/dist/core/git.d.ts
CHANGED
|
@@ -11,7 +11,11 @@ export interface GitSyncOptions {
|
|
|
11
11
|
timeoutMs?: number;
|
|
12
12
|
}
|
|
13
13
|
export declare function gitSync(args: string[], cwd?: string, input?: string, options?: GitSyncOptions): GitResult;
|
|
14
|
-
export
|
|
14
|
+
export interface GitAsyncOptions {
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
}
|
|
18
|
+
export declare function gitAsync(args: string[], cwd?: string, input?: string, options?: GitAsyncOptions): Promise<GitResult>;
|
|
15
19
|
export declare function clone(url: string, dest: string, opts?: {
|
|
16
20
|
ref?: string;
|
|
17
21
|
depth?: number;
|
package/dist/core/git.js
CHANGED
|
@@ -10,22 +10,36 @@ export function gitSync(args, cwd, input, options = {}) {
|
|
|
10
10
|
const timedOut = res.error?.code === 'ETIMEDOUT';
|
|
11
11
|
return { status, stdout, stderr, ...(completed ? {} : { executionFailed: true }), ...(processError === undefined ? {} : { processError }), ...(timedOut ? { timedOut: true } : {}) };
|
|
12
12
|
}
|
|
13
|
-
export async function gitAsync(args, cwd, input) {
|
|
13
|
+
export async function gitAsync(args, cwd, input, options = {}) {
|
|
14
14
|
return new Promise((resolve) => {
|
|
15
|
-
const child = spawn('git', args, { cwd });
|
|
15
|
+
const child = spawn('git', args, { cwd, ...(options.env === undefined ? {} : { env: options.env }) });
|
|
16
16
|
if (input === undefined)
|
|
17
17
|
child.stdin.end();
|
|
18
18
|
else
|
|
19
19
|
child.stdin.end(input);
|
|
20
20
|
let stdout = '';
|
|
21
21
|
let stderr = '';
|
|
22
|
+
let timedOut = false;
|
|
23
|
+
let settled = false;
|
|
24
|
+
const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
25
|
+
timedOut = true;
|
|
26
|
+
child.kill('SIGTERM');
|
|
27
|
+
}, options.timeoutMs);
|
|
28
|
+
const settle = (result) => {
|
|
29
|
+
if (settled)
|
|
30
|
+
return;
|
|
31
|
+
settled = true;
|
|
32
|
+
if (timer !== undefined)
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
resolve(result);
|
|
35
|
+
};
|
|
22
36
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
23
37
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
24
38
|
child.on('close', (status) => {
|
|
25
39
|
const code = typeof status === 'number' ? status : 1;
|
|
26
|
-
|
|
40
|
+
settle({ status: code, stdout, stderr, ...(timedOut ? { timedOut: true, executionFailed: true } : {}) });
|
|
27
41
|
});
|
|
28
|
-
child.on('error', (e) =>
|
|
42
|
+
child.on('error', (e) => settle({ status: 1, stdout: '', stderr: String(e), executionFailed: true, processError: String(e) }));
|
|
29
43
|
});
|
|
30
44
|
}
|
|
31
45
|
export function clone(url, dest, opts = {}) {
|
|
@@ -62,6 +62,8 @@ export interface SpawnChildOpts {
|
|
|
62
62
|
worktree?: boolean;
|
|
63
63
|
/** Local branch the managed worktree is cut from and later landed onto. */
|
|
64
64
|
worktreeBase?: string;
|
|
65
|
+
/** Invocation-selected repository from which to cut a managed worktree. */
|
|
66
|
+
worktreeCwd?: string;
|
|
65
67
|
/** Preallocated node id, used when a managed worktree must be named before birth. */
|
|
66
68
|
nodeId?: string;
|
|
67
69
|
/** Hidden ambient "current situation" text — NOT the visible prompt/body.
|
|
@@ -251,7 +251,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
251
251
|
let spawnCwd = opts.cwd;
|
|
252
252
|
try {
|
|
253
253
|
if (wantsWorktree) {
|
|
254
|
-
managedWorktree = createManagedWorktree(opts.cwd, nodeId, opts.worktreeBase);
|
|
254
|
+
managedWorktree = createManagedWorktree(opts.worktreeCwd ?? opts.cwd, nodeId, opts.worktreeBase);
|
|
255
255
|
spawnCwd = managedWorktree.path;
|
|
256
256
|
}
|
|
257
257
|
// Spine: a managed child reports up to its spawner (has a manager); an
|
|
@@ -336,7 +336,7 @@ export async function reconcileManagedWorktree(nodeId, guard, now = Date.now())
|
|
|
336
336
|
return { status: 'skipped', reason: 'already_reconciled' };
|
|
337
337
|
const cwd = existsSync(wt.path) ? wt.path : wt.repo_root;
|
|
338
338
|
if (!existsSync(cwd)) {
|
|
339
|
-
return
|
|
339
|
+
return persistComplete(nodeId, wt, guard);
|
|
340
340
|
}
|
|
341
341
|
const commonDir = await commonGitDirAsync(cwd);
|
|
342
342
|
if (commonDir === null) {
|
|
@@ -423,9 +423,15 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
423
423
|
const list = await gitAsync(['worktree', 'list', '--porcelain'], wt.path);
|
|
424
424
|
if (list.status !== 0)
|
|
425
425
|
return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
|
|
426
|
-
|
|
426
|
+
// An explicit `close` already proved a branch-moved checkout's identity and
|
|
427
|
+
// authorized its later disposal for THIS exact branch — never adopted here.
|
|
428
|
+
// Only the recorded authorization changes which branch this pass expects;
|
|
429
|
+
// everything else below is re-verified exactly as for an ordinary record.
|
|
430
|
+
const disposal = wt.disposal;
|
|
431
|
+
const expectedBranch = disposal?.observed_branch ?? wt.branch;
|
|
432
|
+
const registeredPath = registeredCheckoutPath(list.stdout, wt.path, expectedBranch);
|
|
427
433
|
if (registeredPath === null) {
|
|
428
|
-
return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${
|
|
434
|
+
return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${expectedBranch}; crtr will not act on a path it cannot prove it owns` };
|
|
429
435
|
}
|
|
430
436
|
const main = mainWorktreePath(list.stdout);
|
|
431
437
|
if (main === null || samePath(main, registeredPath) || !existsSync(main)) {
|
|
@@ -439,8 +445,8 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
439
445
|
const head = await gitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
|
|
440
446
|
if (head.status !== 0)
|
|
441
447
|
return { status: 'failed', reason: 'branch_check_failed', detail: detailOf(head) };
|
|
442
|
-
if (head.stdout.trim() !==
|
|
443
|
-
return { status: 'refused', reason: 'wrong_branch', detail: `expected ${
|
|
448
|
+
if (head.stdout.trim() !== expectedBranch) {
|
|
449
|
+
return { status: 'refused', reason: 'wrong_branch', detail: `expected ${expectedBranch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
|
|
444
450
|
}
|
|
445
451
|
// Uncommitted work blocks removal unconditionally. No containment proof
|
|
446
452
|
// overrides this: those reason about committed content, and nothing here has
|
|
@@ -454,6 +460,23 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
454
460
|
let branchSha = await revParseRef(wt.path, 'HEAD');
|
|
455
461
|
if (branchSha === null)
|
|
456
462
|
return { status: 'failed', reason: 'head_unreadable', detail: `could not read HEAD in ${wt.path}` };
|
|
463
|
+
if (disposal !== undefined) {
|
|
464
|
+
// Re-verify the exact identity `close` authorized before mutating anything.
|
|
465
|
+
// A mismatch invalidates the authorization instead of adopting whatever is
|
|
466
|
+
// observed now — only the explicit command that wrote it may refresh it.
|
|
467
|
+
const commonDir = await commonGitDirAsync(wt.path);
|
|
468
|
+
if (commonDir !== disposal.common_dir || branchSha !== disposal.observed_sha) {
|
|
469
|
+
return { status: 'refused', reason: 'disposal_authorization_stale', detail: `${wt.path} changed since \`close\` authorized its disposal; rerun \`crtr node worktree close\` from that node to re-authorize, or ask the user with \`crtr human send\`` };
|
|
470
|
+
}
|
|
471
|
+
if (!guard.stillEligible())
|
|
472
|
+
return NODE_REVIVED;
|
|
473
|
+
const removed = await gitAsync(['worktree', 'remove', registeredPath], main);
|
|
474
|
+
if (removed.status !== 0)
|
|
475
|
+
return { status: 'refused', reason: 'worktree_remove_failed', detail: detailOf(removed) };
|
|
476
|
+
// `disposal.observed_branch` is not a `crtr/*` branch crouter created — it
|
|
477
|
+
// is preserved, never deleted, unlike the ordinary path below.
|
|
478
|
+
return persistComplete(nodeId, wt, guard);
|
|
479
|
+
}
|
|
457
480
|
let proof = await proveContained(wt.path, wt, branchSha);
|
|
458
481
|
let stash;
|
|
459
482
|
if (proof === null && wt.land_intent !== undefined) {
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -115,17 +115,6 @@ export interface AutoDroppedWorktree {
|
|
|
115
115
|
* explicit close: removal and branch deletion are deferred until that owner
|
|
116
116
|
* has exited. */
|
|
117
117
|
export declare function autoDropCleanManagedWorktreeLocked(nodeId: string, wt: ManagedWorktree, proof: CleanEmptyProof): AutoDroppedWorktree | null;
|
|
118
|
-
|
|
119
|
-
* nothing to land is auto-dropped instead of blocking on an explicit
|
|
120
|
-
* `node worktree close` — one the caller may have been told NOT to run
|
|
121
|
-
* because a manager already landed the same change another way, or
|
|
122
|
-
* that never had anything to close in the first place (a read-only
|
|
123
|
-
* review node). Returns null and changes nothing when the worktree is not
|
|
124
|
-
* PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
|
|
125
|
-
* concurrent mutation invalidates that proof before the record is closed
|
|
126
|
-
* (see `autoDropCleanManagedWorktreeLocked`); the caller's existing
|
|
127
|
-
* `open_managed_worktree` guard then still applies unchanged — real
|
|
128
|
-
* commits, real uncommitted changes, and a lost race all require the
|
|
129
|
-
* explicit path. */
|
|
118
|
+
export declare function branchIsPushedAtCurrentTip(wt: ManagedWorktree): Promise<boolean>;
|
|
130
119
|
export declare function autoDropCleanManagedWorktree(nodeId: string): AutoDroppedWorktree | null;
|
|
131
120
|
export {};
|