@north-light/crouter 0.3.258 → 0.3.260
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/api/dto/attach.d.ts +6 -0
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/clients/attach/command.js +3 -1
- package/dist/clients/attach/input/capabilities.d.ts +3 -0
- package/dist/clients/attach/input/capabilities.js +7 -3
- package/dist/clients/attach/input/controller.d.ts +1 -1
- package/dist/clients/attach/input/controller.js +31 -17
- package/dist/clients/attach/session/connection.d.ts +12 -3
- package/dist/clients/attach/session/connection.js +40 -25
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +6 -2
- package/dist/clients/attach/session/reconnect.d.ts +7 -3
- package/dist/clients/attach/session/reconnect.js +13 -2
- package/dist/clients/attach/slash/dispatch.js +1 -1
- package/dist/clients/attach/viewer.d.ts +1 -1
- package/dist/clients/attach/viewer.js +742 -742
- package/dist/commands/pkg/plugin-inspect.js +2 -1
- package/dist/commands/surface/node/cycle.js +2 -2
- package/dist/commands/surface/node/focus.js +11 -11
- package/dist/commands/surface/node/placement.js +14 -5
- package/dist/commands/sys/doctor.d.ts +35 -0
- package/dist/commands/sys/doctor.js +22 -2
- package/dist/core/__tests__/integration/command-plugins.test.js +317 -2
- package/dist/core/broker-client/client.d.ts +4 -5
- package/dist/core/broker-client/client.js +9 -5
- package/dist/core/canvas/browse/model.d.ts +3 -3
- package/dist/core/canvas/browse/model.js +3 -3
- package/dist/core/canvas/browse/render.js +3 -3
- package/dist/core/command-manifests/manifest.d.ts +5 -3
- package/dist/core/command-manifests/manifest.js +20 -31
- package/dist/core/command-manifests/registry.d.ts +3 -0
- package/dist/core/command-manifests/schema.d.ts +12 -2
- package/dist/core/command-manifests/schema.js +24 -11
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/discovery.d.ts +1 -1
- package/dist/core/command-plugins/discovery.js +10 -4
- package/dist/core/command-plugins/extensions.d.ts +20 -0
- package/dist/core/command-plugins/extensions.js +166 -0
- package/dist/core/command-plugins/transport/exec-invoke.d.ts +15 -2
- package/dist/core/command-plugins/transport/exec-invoke.js +11 -7
- package/dist/core/help.d.ts +5 -0
- package/dist/core/help.js +3 -0
- package/dist/core/runtime/revive.d.ts +19 -6
- package/dist/core/runtime/revive.js +34 -9
- package/dist/daemon/api/handlers/attach.js +24 -20
- package/dist/daemon/api/handlers/nodes.js +7 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -106,7 +106,7 @@ export const pluginShow = defineLeaf({
|
|
|
106
106
|
{ name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
|
|
107
107
|
{ name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
|
|
108
108
|
{ name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
|
|
109
|
-
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
|
|
109
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), extensibleBranches: string[] (mount names accepting repository fragments), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
|
|
110
110
|
{ name: 'hooks', type: 'object', required: false, constraint: 'Present for every hook declaration, including hook-only plugins. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
|
|
111
111
|
],
|
|
112
112
|
outputKind: 'object',
|
|
@@ -146,6 +146,7 @@ export const pluginShow = defineLeaf({
|
|
|
146
146
|
commands = {
|
|
147
147
|
manifestPath: v.manifestPath,
|
|
148
148
|
mounts: v.contributions.map((c) => c.node.name),
|
|
149
|
+
extensibleBranches: v.contributions.filter((c) => c.node.extensible === true).map((c) => c.node.name),
|
|
149
150
|
issues: v.issues,
|
|
150
151
|
};
|
|
151
152
|
}
|
|
@@ -55,7 +55,7 @@ export const surfaceNodeCycleLeaf = defineLeaf({
|
|
|
55
55
|
{ name: 'from', type: 'string', required: false, constraint: 'The node you cycled away from.' },
|
|
56
56
|
],
|
|
57
57
|
outputKind: 'object',
|
|
58
|
-
effects: ['Brings the neighbor\'s viewer forefront beside the caller (like `surface node focus`); the viewer you were watching drops to the background.', '
|
|
58
|
+
effects: ['Brings the neighbor\'s viewer forefront beside the caller (like `surface node focus`); the viewer you were watching drops to the background.', 'Opens a parked resident from its saved snapshot without waking it; submitted input wakes it through the normal message path.'],
|
|
59
59
|
},
|
|
60
60
|
run: async (input) => {
|
|
61
61
|
const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane ?? undefined;
|
|
@@ -73,7 +73,7 @@ export const surfaceNodeCycleLeaf = defineLeaf({
|
|
|
73
73
|
if (target === null)
|
|
74
74
|
return { focused: false, from: fromId };
|
|
75
75
|
// Placement brings the neighbor's viewer forefront beside the caller; the
|
|
76
|
-
// server
|
|
76
|
+
// server keeps an exact parked resident offline until submitted input.
|
|
77
77
|
const res = await focusViewer(targetId, { cwd: target.cwd, name: target.name, pane });
|
|
78
78
|
return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
|
|
79
79
|
},
|
|
@@ -6,11 +6,11 @@ import { cliClient, rethrowAsCliError } from '../../api-client.js';
|
|
|
6
6
|
import { currentTmux, displayPopup, focusViewer, shellQuote } from './placement.js';
|
|
7
7
|
export const surfaceNodeFocusLeaf = defineLeaf({
|
|
8
8
|
name: 'focus',
|
|
9
|
-
description: 'bring a node into view without
|
|
10
|
-
whenToUse: 'you want to bring a specific node into view — a live node
|
|
9
|
+
description: 'bring a node into view without waking parked or finished work',
|
|
10
|
+
whenToUse: 'you want to bring a specific node into view — a live node opens beside your pane, while a parked resident opens its saved conversation without waking until input is submitted. FINISHED work — a terminal worker, or any node that pushed a final report — opens its saved transcript instead of being put back to work. Use `node lifecycle revive` to deliberately relaunch finished work anyway. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
|
|
11
11
|
help: {
|
|
12
12
|
name: 'surface node focus',
|
|
13
|
-
summary: 'bring a node into view — attach
|
|
13
|
+
summary: 'bring a node into view — attach live, open a parked resident without waking it, or inspect finished work as a saved transcript',
|
|
14
14
|
params: [
|
|
15
15
|
{ kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
|
|
16
16
|
{ kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Always open a NEW viewer SIDE-BY-SIDE with your current pane instead of moving/reusing the node\'s existing viewer. Two agents on screen at once (F4).' },
|
|
@@ -20,11 +20,11 @@ export const surfaceNodeFocusLeaf = defineLeaf({
|
|
|
20
20
|
output: [
|
|
21
21
|
{ name: 'focused', type: 'boolean', required: true, constraint: 'True when the node\'s viewer was brought into view.' },
|
|
22
22
|
{ name: 'session', type: 'string', required: false, constraint: 'The tmux session the viewer lives in.' },
|
|
23
|
-
{ name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing had to
|
|
23
|
+
{ name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing had to recover a non-parked node\'s broker; false for an already attached viewer, an offline parked resident, or saved-transcript inspection.' },
|
|
24
24
|
{ name: 'in_place', type: 'boolean', required: true, constraint: 'True when an existing viewer was navigated to (no new pane); false when a fresh viewer pane was opened or moved beside you.' },
|
|
25
25
|
],
|
|
26
26
|
outputKind: 'object',
|
|
27
|
-
effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For
|
|
27
|
+
effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For an unfinalized parked resident: opens the normal conversation viewer from its saved snapshot without launching a broker or changing lifecycle; submitting input sends the durable human message that wakes it.', 'For finished work (a terminal node, or any node with a final report): opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a conversation viewer: controls whether the viewer splits beside or replaces the caller pane.'],
|
|
28
28
|
},
|
|
29
29
|
run: async (input) => {
|
|
30
30
|
const id = input['node'];
|
|
@@ -59,16 +59,16 @@ export const surfaceNodeFocusLeaf = defineLeaf({
|
|
|
59
59
|
// any node that pushed a final report) is archive: show its persisted
|
|
60
60
|
// transcript, because reviving it would set completed work back in motion, and
|
|
61
61
|
// `node lifecycle revive` remains the explicit way to do that on purpose.
|
|
62
|
-
// A RESIDENT node with no final report never finished — its `done`
|
|
63
|
-
//
|
|
64
|
-
//
|
|
62
|
+
// A RESIDENT node with no final report never finished — its parked `done`
|
|
63
|
+
// falls through to the conversation path, where focus can seat its saved
|
|
64
|
+
// snapshot without launching an engine.
|
|
65
65
|
if (!isLiveStatus(node.status) && isFinishedWork({ lifecycle: node.lifecycle, finalized: node.final_report != null })) {
|
|
66
66
|
const focused = displayPopup(pane, node.cwd, `crtr node inspect transcript ${shellQuote(id)}`);
|
|
67
67
|
return { focused, session: null, revived: false, in_place: false };
|
|
68
68
|
}
|
|
69
|
-
// Local viewer tail: open/move the
|
|
70
|
-
// The server
|
|
71
|
-
// own
|
|
69
|
+
// Local viewer tail: open/move the normal conversation viewer beside the
|
|
70
|
+
// caller. The server owns the serialized live-vs-parked decision; pure tmux
|
|
71
|
+
// operations own pane placement.
|
|
72
72
|
const res = await focusViewer(id, {
|
|
73
73
|
cwd: node.cwd,
|
|
74
74
|
name: node.name,
|
|
@@ -7,6 +7,11 @@ export function viewerEnv() {
|
|
|
7
7
|
const crtrHome = envHomeOverride();
|
|
8
8
|
return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
|
|
9
9
|
}
|
|
10
|
+
/** Internal startup policy for viewers opened by focus/navigation. Direct
|
|
11
|
+
* `surface attach` has no marker and keeps its activating default. */
|
|
12
|
+
function focusViewerEnv() {
|
|
13
|
+
return { ...viewerEnv(), CRTR_ATTACH_WAKE_PARKED: '0' };
|
|
14
|
+
}
|
|
10
15
|
/** The live viewer pane for `nodeId` (the pane a surface-attach viewer tagged
|
|
11
16
|
* `@crtr_node`), or undefined. Pure tag scan over live panes. */
|
|
12
17
|
export function viewerPaneOf(nodeId) {
|
|
@@ -22,7 +27,7 @@ export function viewerPaneOf(nodeId) {
|
|
|
22
27
|
/** Open a fresh surface-attach viewer SPLIT beside `callerPane` and rebalance
|
|
23
28
|
* its window to even side-by-side widths. Pure tmux. */
|
|
24
29
|
function openBesideCaller(callerPane, nodeId, cwd, callerSession, revived) {
|
|
25
|
-
const opened = splitWindow(callerPane, { cwd, env:
|
|
30
|
+
const opened = splitWindow(callerPane, { cwd, env: focusViewerEnv(), command: `crtr surface attach to ${nodeId}` });
|
|
26
31
|
if (opened === null)
|
|
27
32
|
return { focused: false, session: null, inPlace: false, revived };
|
|
28
33
|
const loc = paneLocation(opened);
|
|
@@ -40,8 +45,12 @@ export async function focusViewer(nodeId, opts) {
|
|
|
40
45
|
if (callerPane === undefined || callerPane === '') {
|
|
41
46
|
return { focused: false, session: null, inPlace: false, revived: false };
|
|
42
47
|
}
|
|
43
|
-
// (a) Server ensures
|
|
44
|
-
|
|
48
|
+
// (a) Server ensures a live broker is attachable, but leaves an exact parked
|
|
49
|
+
// resident untouched. This policy is unconditional so an active→parked race
|
|
50
|
+
// is decided at the daemon's synchronous fleet/launch boundary.
|
|
51
|
+
const attach = await cliClient()
|
|
52
|
+
.ensureAttach(nodeId, { resume: true, revive: true, wakeParked: false })
|
|
53
|
+
.catch(rethrowAsCliError);
|
|
45
54
|
const revived = attach.revived;
|
|
46
55
|
const callerSession = paneLocation(callerPane)?.session ?? null;
|
|
47
56
|
// (b0) --in-place → the caller pane BECOMES this node's viewer, replacing what
|
|
@@ -57,8 +66,8 @@ export async function focusViewer(nodeId, opts) {
|
|
|
57
66
|
catch { /* best-effort */ }
|
|
58
67
|
const window = windowOfPane(callerPane);
|
|
59
68
|
const env = priorNodeId !== undefined && priorNodeId !== ''
|
|
60
|
-
? { ...
|
|
61
|
-
:
|
|
69
|
+
? { ...focusViewerEnv(), CRTR_ATTACH_ROSTER: priorNodeId }
|
|
70
|
+
: focusViewerEnv();
|
|
62
71
|
const ok = respawnPaneSync({ pane: callerPane, cwd: opts.cwd, env, command: `crtr surface attach to ${nodeId}` });
|
|
63
72
|
if (ok && window !== null)
|
|
64
73
|
renameWindow(window, opts.name);
|
|
@@ -1 +1,36 @@
|
|
|
1
|
+
import { type Scope } from '../../types.js';
|
|
2
|
+
type CheckStatus = 'pass' | 'fail';
|
|
3
|
+
type CheckScope = Scope | 'canvas';
|
|
4
|
+
/**
|
|
5
|
+
* Structured fix action for a failing check. Surfaced on each
|
|
6
|
+
* non-pass result so an agent reading doctor output can apply it directly
|
|
7
|
+
* when `--fix` is not used (or when --fix did not auto-apply). Every
|
|
8
|
+
* remediation includes the exact action and payload — no inference required.
|
|
9
|
+
*/
|
|
10
|
+
interface Remediation {
|
|
11
|
+
kind: 'remove_config_key' | 'rm_path' | 'human_action';
|
|
12
|
+
description: string;
|
|
13
|
+
scope?: Scope;
|
|
14
|
+
configKey?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
command?: string;
|
|
17
|
+
}
|
|
18
|
+
interface CheckResult {
|
|
19
|
+
scope: CheckScope;
|
|
20
|
+
name: string;
|
|
21
|
+
status: CheckStatus;
|
|
22
|
+
message: string;
|
|
23
|
+
fixed?: boolean;
|
|
24
|
+
remediation?: Remediation;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validate the command manifest + executable path of every effective command
|
|
28
|
+
* plugin (the deduped, enabled set that actually composes into the tree).
|
|
29
|
+
* Purely static — the plugin binary is NEVER executed, and no remediation ever
|
|
30
|
+
* chmods or rewrites third-party plugin content (every fix is a human_action:
|
|
31
|
+
* disable/update/remove the plugin). Only plugins whose scope is in `scopes`
|
|
32
|
+
* are reported, so `--scope` narrows these checks too.
|
|
33
|
+
*/
|
|
34
|
+
export declare function runCommandPluginChecks(scopes: Scope[]): Promise<CheckResult[]>;
|
|
1
35
|
export declare const sysDoctorLeaf: import("../../core/command.js").LeafDef;
|
|
36
|
+
export {};
|
|
@@ -14,6 +14,9 @@ import { readFault } from '../../core/runtime/fault.js';
|
|
|
14
14
|
import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
|
|
15
15
|
import { detectPackageManager } from './setup-core.js';
|
|
16
16
|
import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
|
|
17
|
+
import { adaptPluginContributions } from '../../core/command-plugins/compose.js';
|
|
18
|
+
import { resolveCommandRegistry } from '../../core/command-manifests/registry.js';
|
|
19
|
+
import { applyCommandExtensions, orphanCommandExtensionValidations } from '../../core/command-plugins/extensions.js';
|
|
17
20
|
import { discoverHookRegistry } from '../../core/command-hooks/discovery.js';
|
|
18
21
|
import { resolveBinContributions } from '../../core/runtime/bin-contributions.js';
|
|
19
22
|
import { inspectHumanActions } from '../../core/human-actions.js';
|
|
@@ -116,7 +119,7 @@ function faultRemediation(fault, nodeId) {
|
|
|
116
119
|
* disable/update/remove the plugin). Only plugins whose scope is in `scopes`
|
|
117
120
|
* are reported, so `--scope` narrows these checks too.
|
|
118
121
|
*/
|
|
119
|
-
async function runCommandPluginChecks(scopes) {
|
|
122
|
+
export async function runCommandPluginChecks(scopes) {
|
|
120
123
|
const results = [];
|
|
121
124
|
const inScope = new Set(scopes);
|
|
122
125
|
const reserved = new Set(SUBTREE_NAMES);
|
|
@@ -124,7 +127,8 @@ async function runCommandPluginChecks(scopes) {
|
|
|
124
127
|
// collisions surface here. A collision no longer costs anyone their command:
|
|
125
128
|
// each claimant mounts under an origin-qualified name, so it is reported as a
|
|
126
129
|
// healthy mount rather than a failure demanding the user remove a plugin.
|
|
127
|
-
|
|
130
|
+
const validations = validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths());
|
|
131
|
+
for (const v of validations) {
|
|
128
132
|
const plugin = v.plugin;
|
|
129
133
|
if (!inScope.has(plugin.scope))
|
|
130
134
|
continue;
|
|
@@ -143,6 +147,22 @@ async function runCommandPluginChecks(scopes) {
|
|
|
143
147
|
results.push(failCheck(plugin.scope, `plugin:${plugin.name}:commands[${i}]`, `${issue.code}${detail}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, remediation));
|
|
144
148
|
});
|
|
145
149
|
}
|
|
150
|
+
if (inScope.has('project')) {
|
|
151
|
+
const registry = resolveCommandRegistry(adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions, reserved);
|
|
152
|
+
const extensions = applyCommandExtensions(registry.contributions);
|
|
153
|
+
const allExtensionChecks = [...extensions.validations, ...orphanCommandExtensionValidations(registry.contributions)];
|
|
154
|
+
for (const extension of allExtensionChecks) {
|
|
155
|
+
if (extension.issue === undefined) {
|
|
156
|
+
results.push(pass('project', `extension:${extension.branch}`, `repository fragment valid: ${extension.fragmentPath}`));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const issue = extension.issue;
|
|
160
|
+
results.push(failCheck('project', `extension:${extension.branch}`, `${issue.code} at ${issue.path ?? extension.fragmentPath}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, {
|
|
161
|
+
kind: 'human_action',
|
|
162
|
+
description: issue.next,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
146
166
|
return results;
|
|
147
167
|
}
|
|
148
168
|
/** Statically inspect the complete effective hook registry. This shares the
|
|
@@ -29,7 +29,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
29
29
|
import { tmpdir } from 'node:os';
|
|
30
30
|
import { join, dirname } from 'node:path';
|
|
31
31
|
import { fileURLToPath } from 'node:url';
|
|
32
|
-
import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../../command-plugins/discovery.js';
|
|
32
|
+
import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, buildExternalCommandSnapshot, } from '../../command-plugins/discovery.js';
|
|
33
33
|
import { composeExternalSubtrees, adaptPluginContributions } from '../../command-plugins/compose.js';
|
|
34
34
|
import { collectHelpAddenda } from '../../command-plugins/help-addenda.js';
|
|
35
35
|
import { resolveCommandRegistry } from '../../command-manifests/registry.js';
|
|
@@ -38,9 +38,11 @@ import { renderRoot, renderBranch, renderLeafArgv } from '../../help.js';
|
|
|
38
38
|
import { renderResult } from '../../render.js';
|
|
39
39
|
import { CrtrError } from '../../errors.js';
|
|
40
40
|
import { resetScopeCache } from '../../scope.js';
|
|
41
|
+
import { createProfile } from '../../profiles/manifest.js';
|
|
41
42
|
import { listInstalledPluginsInRoot } from '../../resolver.js';
|
|
42
43
|
import { pluginShow } from '../../../commands/pkg/plugin-inspect.js';
|
|
43
|
-
import { pluginDisable, pluginEnable, pluginInstall } from '../../../commands/pkg/plugin-manage.js';
|
|
44
|
+
import { pluginDisable, pluginEnable, pluginInstall, pluginUpdate } from '../../../commands/pkg/plugin-manage.js';
|
|
45
|
+
import { runCommandPluginChecks } from '../../../commands/sys/doctor.js';
|
|
44
46
|
// The set of reserved core names discovery must fail closed against. We don't
|
|
45
47
|
// import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
|
|
46
48
|
// explicit set is enough for the collision contract and keeps the test cheap.
|
|
@@ -112,6 +114,45 @@ function commandsJson(topName = 'app') {
|
|
|
112
114
|
],
|
|
113
115
|
};
|
|
114
116
|
}
|
|
117
|
+
function extensibleDemoCommandsJson(extensible = true) {
|
|
118
|
+
return {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
mounts: [{
|
|
121
|
+
parent: [],
|
|
122
|
+
node: {
|
|
123
|
+
kind: 'branch',
|
|
124
|
+
name: 'demo',
|
|
125
|
+
description: 'fixture extensible branch',
|
|
126
|
+
whenToUse: 'testing repository command contributions',
|
|
127
|
+
...(extensible ? { extensible: true } : {}),
|
|
128
|
+
rootEntry: { concept: 'fixture command contributions', description: 'fixture extensible branch', whenToUse: 'testing repository command contributions' },
|
|
129
|
+
summary: 'fixture extensible branch',
|
|
130
|
+
children: [{ kind: 'branch', name: 'built-in', description: 'plugin-owned branch', whenToUse: 'using the plugin-owned command', summary: 'plugin-owned branch', children: [] }],
|
|
131
|
+
},
|
|
132
|
+
}],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function extensionFragment(rootName = 'repo-branch') {
|
|
136
|
+
return {
|
|
137
|
+
schemaVersion: 1,
|
|
138
|
+
transport: { kind: 'exec', executable: 'scripts/extension.js' },
|
|
139
|
+
mounts: [
|
|
140
|
+
{ parent: [], node: { kind: 'branch', name: rootName, description: 'repository branch', whenToUse: 'using repository-defined commands', summary: 'repository branch', children: [] } },
|
|
141
|
+
{ parent: [rootName], node: { kind: 'leaf', name: 'run', description: 'run repository operation', whenToUse: 'testing extension execution', summary: 'run repository operation', params: [{ kind: 'flag', name: 'thing', type: 'string', required: true, constraint: 'fixture input' }], output: [{ name: 'value', type: 'string', required: true, constraint: 'echoed fixture input' }], outputKind: 'object', effects: ['None. Read-only.'] } },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const EXTENSION_EXEC = `#!/usr/bin/env node
|
|
146
|
+
const fs = require('fs');
|
|
147
|
+
let data = '';
|
|
148
|
+
process.stdin.on('data', (chunk) => data += chunk);
|
|
149
|
+
process.stdin.on('end', () => {
|
|
150
|
+
const request = JSON.parse(data);
|
|
151
|
+
if (process.env.EXTENSION_REQUEST_LOG) fs.writeFileSync(process.env.EXTENSION_REQUEST_LOG, JSON.stringify(request));
|
|
152
|
+
if (process.env.EXTENSION_SENTINEL) fs.writeFileSync(process.env.EXTENSION_SENTINEL, 'executed');
|
|
153
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { value: request.input.thing } }));
|
|
154
|
+
});
|
|
155
|
+
`;
|
|
115
156
|
function passthroughCommandsJson() {
|
|
116
157
|
return {
|
|
117
158
|
schemaVersion: 1,
|
|
@@ -210,6 +251,26 @@ after(() => {
|
|
|
210
251
|
}
|
|
211
252
|
resetScopeCache();
|
|
212
253
|
});
|
|
254
|
+
function installExtensionFixture(manifest = extensionFragment()) {
|
|
255
|
+
installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
|
|
256
|
+
const commandsDir = join(projectRoot, 'commands');
|
|
257
|
+
const scriptsDir = join(projectDir, 'scripts');
|
|
258
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
259
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
260
|
+
const fragmentPath = join(commandsDir, 'demo.json');
|
|
261
|
+
const executable = join(scriptsDir, 'extension.js');
|
|
262
|
+
writeFileSync(fragmentPath, JSON.stringify(manifest));
|
|
263
|
+
writeFileSync(executable, EXTENSION_EXEC);
|
|
264
|
+
chmodSync(executable, 0o755);
|
|
265
|
+
return { fragmentPath, executable };
|
|
266
|
+
}
|
|
267
|
+
function extensionBranch(startDir = projectDir, name = 'demo') {
|
|
268
|
+
resetScopeCache();
|
|
269
|
+
const snapshot = buildExternalCommandSnapshot(RESERVED, startDir);
|
|
270
|
+
const branch = composeExternalSubtrees(snapshot).find((node) => node.name === name);
|
|
271
|
+
assert.ok(branch, `expected the extensible ${name} branch`);
|
|
272
|
+
return branch;
|
|
273
|
+
}
|
|
213
274
|
function leafOf(branch, name) {
|
|
214
275
|
const c = branch.children.find((x) => x.name === name);
|
|
215
276
|
assert.ok(c !== undefined && c.kind === 'leaf', `expected leaf ${name}`);
|
|
@@ -384,6 +445,260 @@ describe('end-to-end CLI invocation', () => {
|
|
|
384
445
|
assert.equal(obj.status, 'running');
|
|
385
446
|
});
|
|
386
447
|
});
|
|
448
|
+
// Extensible plugin command branches — repository fragments attach to a marked
|
|
449
|
+
// top-level branch as one native forest and stay entirely inside the existing
|
|
450
|
+
// parser/help/exec contract.
|
|
451
|
+
describe('extensible plugin command branches', () => {
|
|
452
|
+
test('merges native help, executes with extension context, and mirrors JSON', async () => {
|
|
453
|
+
const { fragmentPath } = installExtensionFixture();
|
|
454
|
+
const branch = extensionBranch();
|
|
455
|
+
const help = renderBranch(branch.help);
|
|
456
|
+
assert.match(help, /<command name="demo"/);
|
|
457
|
+
assert.match(help, /<subcommand name="built-in"/);
|
|
458
|
+
assert.match(help, /<subcommand name="repo-branch"[^>]*subcommands="1"/);
|
|
459
|
+
assert.ok(help.indexOf('name="built-in"') < help.indexOf('name="repo-branch"'));
|
|
460
|
+
assert.equal((help.match(/<command name=/g) ?? []).length, 1);
|
|
461
|
+
const repoBranch = branch.children.find((child) => child.name === 'repo-branch');
|
|
462
|
+
assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
|
|
463
|
+
const leaf = leafOf(repoBranch, 'run');
|
|
464
|
+
const leafHelp = renderLeafArgv(leaf.help);
|
|
465
|
+
assert.match(leafHelp, /^demo repo-branch run: run repository operation\./);
|
|
466
|
+
assert.match(leafHelp, /Input/);
|
|
467
|
+
assert.match(leafHelp, /Output \(fields carried in the rendered result\)/);
|
|
468
|
+
assert.match(leafHelp, /Effects/);
|
|
469
|
+
const requestLog = join(projectDir, 'extension-request.json');
|
|
470
|
+
process.env['EXTENSION_REQUEST_LOG'] = requestLog;
|
|
471
|
+
const priorCwd = process.cwd();
|
|
472
|
+
process.chdir(projectDir);
|
|
473
|
+
try {
|
|
474
|
+
assert.deepEqual(await leaf.run({ thing: 'x' }), { value: 'x' });
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
process.chdir(priorCwd);
|
|
478
|
+
delete process.env['EXTENSION_REQUEST_LOG'];
|
|
479
|
+
}
|
|
480
|
+
const request = JSON.parse(readFileSync(requestLog, 'utf8'));
|
|
481
|
+
assert.deepEqual(request.command, ['demo', 'repo-branch', 'run']);
|
|
482
|
+
assert.deepEqual(request.input, { thing: 'x' });
|
|
483
|
+
assert.deepEqual(request.context.extension, { branch: 'demo', root: projectDir });
|
|
484
|
+
assert.equal(request.context.plugin, undefined);
|
|
485
|
+
const root = defineRoot({ tagline: 'test', globals: [], subtrees: [branch] });
|
|
486
|
+
assert.match(renderRoot(root.help), /\[\+2 subcommands — `crtr demo -h`\]/);
|
|
487
|
+
assert.equal(existsSync(join(projectDir, 'EXECUTED')), false);
|
|
488
|
+
assert.ok(existsSync(fragmentPath));
|
|
489
|
+
});
|
|
490
|
+
test('uses a collision-qualified branch name for fragment discovery, doctor, and exec context', async () => {
|
|
491
|
+
installPlugin(userRoot, 'first', { manifest: extensibleDemoCommandsJson() });
|
|
492
|
+
installPlugin(userRoot, 'second', { manifest: extensibleDemoCommandsJson() });
|
|
493
|
+
const commandsDir = join(projectRoot, 'commands');
|
|
494
|
+
const scriptsDir = join(projectDir, 'scripts');
|
|
495
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
496
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
497
|
+
writeFileSync(join(commandsDir, 'first:demo.json'), JSON.stringify(extensionFragment()));
|
|
498
|
+
writeFileSync(join(scriptsDir, 'extension.js'), EXTENSION_EXEC);
|
|
499
|
+
chmodSync(join(scriptsDir, 'extension.js'), 0o755);
|
|
500
|
+
const first = extensionBranch(projectDir, 'first:demo');
|
|
501
|
+
const second = extensionBranch(projectDir, 'second:demo');
|
|
502
|
+
assert.ok(first.children.some((child) => child.name === 'repo-branch'));
|
|
503
|
+
assert.ok(!second.children.some((child) => child.name === 'repo-branch'));
|
|
504
|
+
const repoBranch = first.children.find((child) => child.name === 'repo-branch');
|
|
505
|
+
assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
|
|
506
|
+
const requestLog = join(projectDir, 'collision-extension-request.json');
|
|
507
|
+
process.env['EXTENSION_REQUEST_LOG'] = requestLog;
|
|
508
|
+
const priorCwd = process.cwd();
|
|
509
|
+
process.chdir(projectDir);
|
|
510
|
+
try {
|
|
511
|
+
assert.deepEqual(await leafOf(repoBranch, 'run').run({ thing: 'x' }), { value: 'x' });
|
|
512
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
513
|
+
const extension = checks.find((check) => check.name === 'extension:first:demo');
|
|
514
|
+
assert.ok(extension, JSON.stringify(checks));
|
|
515
|
+
assert.equal(extension.status, 'pass');
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
process.chdir(priorCwd);
|
|
519
|
+
delete process.env['EXTENSION_REQUEST_LOG'];
|
|
520
|
+
}
|
|
521
|
+
const request = JSON.parse(readFileSync(requestLog, 'utf8'));
|
|
522
|
+
assert.deepEqual(request.command, ['first:demo', 'repo-branch', 'run']);
|
|
523
|
+
assert.deepEqual(request.context.extension, { branch: 'first:demo', root: projectDir });
|
|
524
|
+
assert.equal(request.context.plugin, undefined);
|
|
525
|
+
});
|
|
526
|
+
test('runs through the real CLI with native help and --json output', { timeout: 40000 }, () => {
|
|
527
|
+
installExtensionFixture();
|
|
528
|
+
const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
|
|
529
|
+
const run = (args) => execFileSync(process.execPath, [cli, ...args], {
|
|
530
|
+
cwd: projectDir,
|
|
531
|
+
env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
|
|
532
|
+
encoding: 'utf8',
|
|
533
|
+
});
|
|
534
|
+
const branchHelp = run(['demo', '-h']);
|
|
535
|
+
assert.match(branchHelp, /<command name="demo"/);
|
|
536
|
+
assert.match(branchHelp, /<subcommand name="built-in"/);
|
|
537
|
+
assert.match(branchHelp, /<subcommand name="repo-branch"/);
|
|
538
|
+
assert.deepEqual(JSON.parse(run(['demo', 'repo-branch', 'run', '--thing', 'x', '--json'])), { value: 'x' });
|
|
539
|
+
});
|
|
540
|
+
test('discovers per invocation, scopes by cwd, and nearest root wins', () => {
|
|
541
|
+
const { fragmentPath } = installExtensionFixture();
|
|
542
|
+
assert.ok(renderBranch(extensionBranch().help).includes('repo-branch'));
|
|
543
|
+
writeFileSync(fragmentPath, JSON.stringify(extensionFragment('changed')));
|
|
544
|
+
assert.ok(renderBranch(extensionBranch().help).includes('name="changed"'));
|
|
545
|
+
rmSync(fragmentPath);
|
|
546
|
+
assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
|
|
547
|
+
writeFileSync(fragmentPath, JSON.stringify(extensionFragment('outer')));
|
|
548
|
+
const inner = join(projectDir, 'nested');
|
|
549
|
+
const innerScope = join(inner, '.crouter', 'commands');
|
|
550
|
+
mkdirSync(innerScope, { recursive: true });
|
|
551
|
+
mkdirSync(join(inner, 'scripts'), { recursive: true });
|
|
552
|
+
writeFileSync(join(inner, 'scripts', 'extension.js'), EXTENSION_EXEC);
|
|
553
|
+
chmodSync(join(inner, 'scripts', 'extension.js'), 0o755);
|
|
554
|
+
writeFileSync(join(innerScope, 'demo.json'), JSON.stringify(extensionFragment('inner')));
|
|
555
|
+
assert.ok(renderBranch(extensionBranch(inner).help).includes('name="inner"'));
|
|
556
|
+
assert.ok(!renderBranch(extensionBranch(inner).help).includes('name="outer"'));
|
|
557
|
+
assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('name="outer"'));
|
|
558
|
+
});
|
|
559
|
+
test('does not discover fragments from selected-profile projects outside the cwd chain', () => {
|
|
560
|
+
installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
|
|
561
|
+
const profileProject = mintDir('crtr-cmdplugin-profile-project-');
|
|
562
|
+
const fragmentDir = join(profileProject, '.crouter', 'commands');
|
|
563
|
+
const scriptDir = join(profileProject, 'scripts');
|
|
564
|
+
mkdirSync(fragmentDir, { recursive: true });
|
|
565
|
+
mkdirSync(scriptDir, { recursive: true });
|
|
566
|
+
writeFileSync(join(fragmentDir, 'demo.json'), JSON.stringify(extensionFragment()));
|
|
567
|
+
writeFileSync(join(scriptDir, 'extension.js'), EXTENSION_EXEC);
|
|
568
|
+
chmodSync(join(scriptDir, 'extension.js'), 0o755);
|
|
569
|
+
process.env['CRTR_PROFILE_ID'] = createProfile('extension-fragment-scope', [{ path: profileProject, memory: 'content' }]).profileId;
|
|
570
|
+
resetScopeCache();
|
|
571
|
+
assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('repo-branch'));
|
|
572
|
+
});
|
|
573
|
+
test('rejects absolute executable paths and identifies the offending fragment node', async () => {
|
|
574
|
+
const { fragmentPath, executable } = installExtensionFixture();
|
|
575
|
+
const fragment = extensionFragment();
|
|
576
|
+
fragment.transport.executable = executable;
|
|
577
|
+
writeFileSync(fragmentPath, JSON.stringify(fragment));
|
|
578
|
+
const help = renderBranch(extensionBranch().help);
|
|
579
|
+
assert.match(help, /<extension-issue/);
|
|
580
|
+
assert.ok(!help.includes('repo-branch'));
|
|
581
|
+
const priorCwd = process.cwd();
|
|
582
|
+
process.chdir(projectDir);
|
|
583
|
+
try {
|
|
584
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
585
|
+
const check = checks.find((candidate) => candidate.name === 'extension:demo');
|
|
586
|
+
assert.ok(check, JSON.stringify(checks));
|
|
587
|
+
assert.equal(check.status, 'fail');
|
|
588
|
+
assert.match(check.message, new RegExp(`${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:transport\\.executable`));
|
|
589
|
+
}
|
|
590
|
+
finally {
|
|
591
|
+
process.chdir(priorCwd);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
test('rejects the complete fragment with scoped help notice and doctor remediation', async () => {
|
|
595
|
+
const { fragmentPath } = installExtensionFixture();
|
|
596
|
+
const bad = extensionFragment();
|
|
597
|
+
bad['unknown'] = true;
|
|
598
|
+
writeFileSync(fragmentPath, JSON.stringify(bad));
|
|
599
|
+
const help = renderBranch(extensionBranch().help);
|
|
600
|
+
assert.match(help, /name="built-in"/);
|
|
601
|
+
assert.ok(!help.includes('repo-branch'));
|
|
602
|
+
assert.match(help, new RegExp(`<extension-issue fragment="${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
|
|
603
|
+
assert.match(help, /unknown top-level keys/);
|
|
604
|
+
const sentinel = join(projectDir, 'extension-executed');
|
|
605
|
+
process.env['EXTENSION_SENTINEL'] = sentinel;
|
|
606
|
+
const priorCwd = process.cwd();
|
|
607
|
+
process.chdir(projectDir);
|
|
608
|
+
try {
|
|
609
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
610
|
+
const check = checks.find((candidate) => candidate.name === 'extension:demo');
|
|
611
|
+
assert.ok(check, JSON.stringify(checks));
|
|
612
|
+
assert.equal(check.status, 'fail');
|
|
613
|
+
assert.match(check.message, /command_extension_invalid/);
|
|
614
|
+
assert.match(check.message, new RegExp(fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
615
|
+
assert.match(check.remediation.description, /regenerate/i);
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
process.chdir(priorCwd);
|
|
619
|
+
delete process.env['EXTENSION_SENTINEL'];
|
|
620
|
+
}
|
|
621
|
+
assert.equal(existsSync(sentinel), false);
|
|
622
|
+
});
|
|
623
|
+
test('requires the marker, rejects passthrough/rootEntry/conflicts, and leaves unmarked passthrough inert', async () => {
|
|
624
|
+
const { fragmentPath } = installExtensionFixture();
|
|
625
|
+
writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson(false)));
|
|
626
|
+
assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
|
|
627
|
+
const priorCwd = process.cwd();
|
|
628
|
+
process.chdir(projectDir);
|
|
629
|
+
try {
|
|
630
|
+
const checks = await runCommandPluginChecks(['project']);
|
|
631
|
+
const orphan = checks.find((candidate) => candidate.name === 'extension:demo');
|
|
632
|
+
assert.ok(orphan, JSON.stringify(checks));
|
|
633
|
+
assert.equal(orphan.status, 'fail');
|
|
634
|
+
assert.match(orphan.message, /no effective plugin branch is extensible/);
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
process.chdir(priorCwd);
|
|
638
|
+
}
|
|
639
|
+
writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson()));
|
|
640
|
+
const variants = [
|
|
641
|
+
['passthrough', (fragment) => { fragment['mounts'][0]['node']['passthrough'] = { bin: 'x', installHint: 'x' }; }],
|
|
642
|
+
['rootEntry', (fragment) => { fragment['mounts'][0]['node']['rootEntry'] = { concept: 'x', description: 'x', whenToUse: 'x' }; }],
|
|
643
|
+
];
|
|
644
|
+
for (const [name, mutate] of variants) {
|
|
645
|
+
const fragment = extensionFragment();
|
|
646
|
+
mutate(fragment);
|
|
647
|
+
writeFileSync(fragmentPath, JSON.stringify(fragment));
|
|
648
|
+
const help = renderBranch(extensionBranch().help);
|
|
649
|
+
assert.match(help, /<extension-issue/);
|
|
650
|
+
assert.ok(!help.includes('repo-branch'), name);
|
|
651
|
+
}
|
|
652
|
+
writeFileSync(fragmentPath, JSON.stringify(extensionFragment('built-in')));
|
|
653
|
+
const conflictHelp = renderBranch(extensionBranch().help);
|
|
654
|
+
assert.match(conflictHelp, /<extension-issue/);
|
|
655
|
+
assert.match(conflictHelp, /conflicts with a child/);
|
|
656
|
+
installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson() });
|
|
657
|
+
writeFileSync(join(projectRoot, 'commands', 'capture.json'), JSON.stringify(extensionFragment()));
|
|
658
|
+
resetScopeCache();
|
|
659
|
+
const snapshot = buildExternalCommandSnapshot(RESERVED, projectDir);
|
|
660
|
+
const capture = composeExternalSubtrees(snapshot).find((branch) => branch.name === 'capture');
|
|
661
|
+
assert.ok(capture?.passthrough);
|
|
662
|
+
assert.equal(capture.children.length, 0);
|
|
663
|
+
});
|
|
664
|
+
test('install and update validate the marker but never inspect repository fragments', async () => {
|
|
665
|
+
const source = join(mintDir('crtr-extensible-source-'), 'fixture');
|
|
666
|
+
mkdirSync(join(source, '.crouter-plugin'), { recursive: true });
|
|
667
|
+
mkdirSync(join(source, 'bin'), { recursive: true });
|
|
668
|
+
const manifest = extensibleDemoCommandsJson();
|
|
669
|
+
manifest.mounts[0].node['name'] = 'install-demo';
|
|
670
|
+
const rootEntry = manifest.mounts[0].node['rootEntry'];
|
|
671
|
+
rootEntry['concept'] = 'install fixture';
|
|
672
|
+
writeFileSync(join(source, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'install-extension-fixture', version: '0.1.0', description: 'fixture', commands: 'commands.json', transport: { kind: 'exec', executable: 'bin/cmd.js' } }));
|
|
673
|
+
writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
|
|
674
|
+
writeFileSync(join(source, 'bin', 'cmd.js'), EXTENSION_EXEC);
|
|
675
|
+
chmodSync(join(source, 'bin', 'cmd.js'), 0o755);
|
|
676
|
+
mkdirSync(join(projectRoot, 'commands'), { recursive: true });
|
|
677
|
+
writeFileSync(join(projectRoot, 'commands', 'install-demo.json'), JSON.stringify({ unknown: true }));
|
|
678
|
+
await pluginInstall.run({ installRef: source, scope: 'user' });
|
|
679
|
+
(manifest.mounts[0].node['children'][0]['extensible'] = true);
|
|
680
|
+
writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
|
|
681
|
+
await assert.rejects(pluginUpdate.run({ name: 'install-extension-fixture' }), /extensible is valid only on a top-level branch mount/);
|
|
682
|
+
});
|
|
683
|
+
test('plugin inspection identifies extensible branches without executing the fragment executable', async () => {
|
|
684
|
+
const { executable } = installExtensionFixture();
|
|
685
|
+
const sentinel = join(projectDir, 'inspect-executed');
|
|
686
|
+
process.env['EXTENSION_SENTINEL'] = sentinel;
|
|
687
|
+
const priorCwd = process.cwd();
|
|
688
|
+
process.chdir(projectDir);
|
|
689
|
+
try {
|
|
690
|
+
const shown = await pluginShow.run({ name: 'extension-fixture' });
|
|
691
|
+
const commands = shown['commands'];
|
|
692
|
+
assert.deepEqual(commands.extensibleBranches, ['demo']);
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
process.chdir(priorCwd);
|
|
696
|
+
delete process.env['EXTENSION_SENTINEL'];
|
|
697
|
+
}
|
|
698
|
+
assert.equal(existsSync(sentinel), false);
|
|
699
|
+
assert.ok(existsSync(executable));
|
|
700
|
+
});
|
|
701
|
+
});
|
|
387
702
|
// 5. Lifecycle without restart (per-invocation discovery)
|
|
388
703
|
describe('lifecycle without restart', () => {
|
|
389
704
|
test('install → appears; disable → gone; edit → visible; remove → gone', () => {
|
|
@@ -22,11 +22,10 @@ export interface BrokerClientOptions {
|
|
|
22
22
|
* re-dialing the same target while the node is still alive — a yield
|
|
23
23
|
* leaves `status='active'` (intent='refresh') and the daemon revives a fresh
|
|
24
24
|
* broker on the same path. It gives up only when the node is genuinely gone:
|
|
25
|
-
* a terminal status (done/dead/canceled) or a reaped row (null). `idle`
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* callers driving a remote attach must not consult this (see viewer.ts). */
|
|
25
|
+
* a terminal status (done/dead/canceled) or a reaped row (null). `idle` and an
|
|
26
|
+
* exact unfinalized parked resident are NOT terminal — either can wake while
|
|
27
|
+
* the viewer remains seated. Local canvas rows only — a remote target has no
|
|
28
|
+
* local row, so callers driving a remote attach must not consult this. */
|
|
30
29
|
export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
|
|
31
30
|
/** The plan-fixed interface (Wave 3): `connect()`, `on('frame', …)`,
|
|
32
31
|
* `send(frame)`, `on('close', …)`, plus `connect`/`error` events. */
|
|
@@ -41,14 +41,18 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
|
|
41
41
|
* re-dialing the same target while the node is still alive — a yield
|
|
42
42
|
* leaves `status='active'` (intent='refresh') and the daemon revives a fresh
|
|
43
43
|
* broker on the same path. It gives up only when the node is genuinely gone:
|
|
44
|
-
* a terminal status (done/dead/canceled) or a reaped row (null). `idle`
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* callers driving a remote attach must not consult this (see viewer.ts). */
|
|
44
|
+
* a terminal status (done/dead/canceled) or a reaped row (null). `idle` and an
|
|
45
|
+
* exact unfinalized parked resident are NOT terminal — either can wake while
|
|
46
|
+
* the viewer remains seated. Local canvas rows only — a remote target has no
|
|
47
|
+
* local row, so callers driving a remote attach must not consult this. */
|
|
49
48
|
export function reconnectShouldGiveUp(row) {
|
|
50
49
|
if (row === null)
|
|
51
50
|
return true;
|
|
51
|
+
if (row.lifecycle === 'resident'
|
|
52
|
+
&& row.status === 'done'
|
|
53
|
+
&& row.intent === 'parked'
|
|
54
|
+
&& row.final_report == null)
|
|
55
|
+
return false;
|
|
52
56
|
return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
|
|
53
57
|
}
|
|
54
58
|
export class BrokerClient extends EventEmitter {
|