@north-light/crouter 0.3.198 → 0.3.199
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/client.d.ts +18 -18
- package/dist/api/client.js +29 -37
- package/dist/api/dto/broker-ops.d.ts +3 -1
- package/dist/api/dto/broker.d.ts +13 -2
- package/dist/api/dto/broker.js +4 -4
- package/dist/api/dto/human.d.ts +6 -5
- package/dist/api/dto/inbox.d.ts +81 -34
- package/dist/api/dto/nodes.d.ts +8 -2
- package/dist/api/dto/profiles.d.ts +23 -0
- package/dist/api/dto/profiles.js +2 -2
- package/dist/api/dto/worktree.d.ts +3 -5
- package/dist/api/routes.d.ts +3 -5
- package/dist/api/routes.js +4 -6
- package/dist/builtin-memory/00-runtime-base.md +6 -6
- package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
- package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -3
- package/dist/builtin-memory/04-orchestration-kernel.md +6 -6
- package/dist/builtin-memory/05-kinds/design/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +2 -2
- package/dist/builtin-memory/05-kinds/review/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -1
- package/dist/builtin-memory/insights/capture.md +2 -2
- package/dist/builtin-memory/insights/init.md +3 -3
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/memory-loading.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/builtin-memory/internal/plugins.md +59 -2
- package/dist/builtin-memory/internal/storage-tiers.md +3 -1
- package/dist/builtin-memory/spec/roadmap.md +2 -2
- package/dist/builtin-memory/testing.md +1 -1
- package/dist/clients/attach/chrome/bash-jobs.js +3 -3
- package/dist/clients/attach/render/chat-view.d.ts +26 -1
- package/dist/clients/attach/render/chat-view.js +61 -11
- package/dist/clients/attach/render/page-block.d.ts +34 -0
- package/dist/clients/attach/render/page-block.js +226 -0
- package/dist/clients/attach/session/whip.d.ts +2 -0
- package/dist/clients/attach/session/whip.js +2 -0
- package/dist/clients/attach/viewer.js +1419 -993
- package/dist/clients/conversation/projection.js +18 -3
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +9 -6
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +13 -21
- package/dist/clients/inbox/controller.js +10 -16
- package/dist/clients/inbox/page-adapter.d.ts +1 -3
- package/dist/clients/inbox/page-adapter.js +3 -4
- package/dist/clients/inbox/resolve.d.ts +1 -1
- package/dist/clients/inbox/resolve.js +3 -3
- package/dist/clients/inbox/review/document-surface.js +3 -1
- package/dist/clients/inbox/review/state.d.ts +2 -1
- package/dist/clients/inbox/review/state.js +5 -16
- package/dist/clients/inbox/tui/input.js +37 -19
- package/dist/clients/inbox/tui/panel.js +5 -31
- package/dist/clients/inbox/tui/render.d.ts +11 -0
- package/dist/clients/inbox/tui/render.js +71 -27
- package/dist/clients/inbox/tui/slots.d.ts +3 -1
- package/dist/clients/inbox/tui/slots.js +16 -12
- package/dist/clients/inbox/tui/types.d.ts +2 -7
- package/dist/clients/inbox/tui.js +2 -2
- package/dist/commands/__tests__/human.test.js +71 -114
- package/dist/commands/__tests__/node-message.test.js +14 -0
- package/dist/commands/attention.js +4 -4
- package/dist/commands/canvas-snapshot.js +1 -1
- package/dist/commands/canvas.js +1 -1
- package/dist/commands/dashboard.js +1 -1
- package/dist/commands/human/{html.d.ts → components.d.ts} +1 -1
- package/dist/commands/human/components.js +77 -0
- package/dist/commands/human/feedback.d.ts +2 -0
- package/dist/commands/human/feedback.js +108 -0
- package/dist/commands/human/prompts.d.ts +13 -5
- package/dist/commands/human/prompts.js +160 -97
- package/dist/commands/human/queue.js +57 -88
- package/dist/commands/human/shared.d.ts +2 -12
- package/dist/commands/human/shared.js +17 -11
- package/dist/commands/human.js +29 -21
- package/dist/commands/memory/origin.js +2 -2
- package/dist/commands/node/bash.js +4 -4
- package/dist/commands/node/inspect.js +3 -3
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/commands/node/message.js +2 -2
- package/dist/commands/node-worktree.js +8 -8
- package/dist/commands/pkg/browse.js +1 -1
- package/dist/commands/pkg/plugin-manage.js +129 -10
- package/dist/commands/profile/delete.js +41 -15
- package/dist/commands/profile.js +1 -1
- package/dist/commands/push.js +1 -1
- package/dist/commands/surface/node/focus.js +29 -14
- package/dist/commands/surface-edit.js +3 -3
- package/dist/commands/surface-inbox.d.ts +2 -0
- package/dist/commands/{human/inbox.js → surface-inbox.js} +14 -18
- package/dist/commands/surface.js +2 -1
- package/dist/commands/sys/doctor.js +60 -3
- package/dist/commands/sys/feedback.js +79 -25
- package/dist/commands/sys/settings.js +1 -1
- package/dist/core/__tests__/cron-broker-capacity.test.js +1 -1
- package/dist/core/__tests__/extension-abort.test.js +5 -2
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -2
- package/dist/core/__tests__/fixtures/fake-engine.js +47 -3
- package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
- package/dist/core/__tests__/human-deliver.test.js +31 -26
- package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
- package/dist/core/__tests__/plugin-page-components.test.js +157 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +40 -0
- package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -6
- package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
- package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
- package/dist/core/__tests__/serial/worktree.test.js +24 -198
- package/dist/core/__tests__/session-cycles.test.js +22 -0
- package/dist/core/__tests__/stop-guard.test.js +4 -4
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
- package/dist/core/bash-jobs.d.ts +6 -0
- package/dist/core/bash-jobs.js +10 -0
- package/dist/core/canvas/__tests__/attention.test.js +8 -5
- package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
- package/dist/core/canvas/attention.js +8 -6
- package/dist/core/canvas/browse/model.d.ts +8 -2
- package/dist/core/canvas/browse/model.js +11 -4
- package/dist/core/canvas/canvas.d.ts +7 -0
- package/dist/core/canvas/canvas.js +37 -0
- package/dist/core/canvas/crons.d.ts +9 -0
- package/dist/core/canvas/crons.js +51 -0
- package/dist/core/canvas/node-order.d.ts +22 -1
- package/dist/core/canvas/node-order.js +26 -1
- package/dist/core/canvas/render-source.d.ts +5 -0
- package/dist/core/canvas/render-source.js +1 -0
- package/dist/core/canvas/types.d.ts +9 -3
- package/dist/core/command-plugins/bundle.d.ts +6 -1
- package/dist/core/command-plugins/bundle.js +26 -13
- package/dist/core/command.js +2 -1
- package/dist/core/config.d.ts +47 -1
- package/dist/core/config.js +137 -4
- package/dist/core/help.js +1 -1
- package/dist/core/human/__tests__/page-catalog.test.js +4 -0
- package/dist/core/human/__tests__/page-tickets.test.js +65 -52
- package/dist/core/human/__tests__/page.test.js +56 -98
- package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -6
- package/dist/core/human/answer-text.js +3 -3
- package/dist/core/human/answer.d.ts +6 -4
- package/dist/core/human/answer.js +4 -10
- package/dist/core/human/component-docs.d.ts +11 -7
- package/dist/core/human/component-docs.js +361 -88
- package/dist/core/human/convention.d.ts +17 -3
- package/dist/core/human/convention.js +36 -5
- package/dist/core/human/feedback-companion.d.ts +20 -0
- package/dist/core/human/feedback-companion.js +98 -0
- package/dist/core/human/feedback.d.ts +65 -0
- package/dist/core/human/feedback.js +116 -0
- package/dist/core/human/page-catalog.d.ts +40 -5
- package/dist/core/human/page-catalog.js +102 -30
- package/dist/core/human/page-errors.d.ts +4 -0
- package/dist/core/human/page-errors.js +7 -0
- package/dist/core/human/page-eval.d.ts +20 -0
- package/dist/core/human/page-eval.js +123 -0
- package/dist/core/human/page-schema.d.ts +23 -191
- package/dist/core/human/page-schema.js +60 -89
- package/dist/core/human/page-synth.d.ts +1 -1
- package/dist/core/human/page-synth.js +15 -18
- package/dist/core/human/page.d.ts +12 -27
- package/dist/core/human/page.js +142 -474
- package/dist/core/human/root.d.ts +6 -2
- package/dist/core/human/root.js +12 -4
- package/dist/core/human/scan.d.ts +3 -4
- package/dist/core/human/scan.js +16 -15
- package/dist/core/human/summary.d.ts +1 -2
- package/dist/core/human/summary.js +2 -2
- package/dist/core/human/tickets.d.ts +20 -14
- package/dist/core/human/tickets.js +91 -31
- package/dist/core/human/types.d.ts +10 -2
- package/dist/core/preview-registry.js +8 -10
- package/dist/core/profiles/default-binding.d.ts +4 -0
- package/dist/core/profiles/default-binding.js +24 -0
- package/dist/core/profiles/deletion-reservation.d.ts +8 -0
- package/dist/core/profiles/deletion-reservation.js +38 -0
- package/dist/core/profiles/manifest.d.ts +3 -0
- package/dist/core/profiles/manifest.js +12 -0
- package/dist/core/profiles/select.js +2 -2
- package/dist/core/review/realize.js +3 -3
- package/dist/core/review/store.d.ts +6 -0
- package/dist/core/review/store.js +31 -0
- package/dist/core/runtime/bearings.d.ts +0 -4
- package/dist/core/runtime/bearings.js +4 -62
- package/dist/core/runtime/bin-contributions.d.ts +44 -0
- package/dist/core/runtime/bin-contributions.js +186 -0
- package/dist/core/runtime/broker/client-registry.d.ts +4 -0
- package/dist/core/runtime/broker/client-registry.js +13 -1
- package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
- package/dist/core/runtime/broker/extension-abort.js +4 -2
- package/dist/core/runtime/broker/fault-retry.js +4 -0
- package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
- package/dist/core/runtime/broker/frame-dispatch.js +5 -6
- package/dist/core/runtime/broker/read-ops.d.ts +1 -1
- package/dist/core/runtime/broker/read-ops.js +2 -2
- package/dist/core/runtime/broker/rebind.d.ts +1 -0
- package/dist/core/runtime/broker/rebind.js +6 -1
- package/dist/core/runtime/broker-extension-render.js +19 -0
- package/dist/core/runtime/broker-protocol.d.ts +14 -0
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +17 -6
- package/dist/core/runtime/front-door-env.d.ts +5 -0
- package/dist/core/runtime/front-door-env.js +16 -0
- package/dist/core/runtime/front-door.d.ts +1 -5
- package/dist/core/runtime/front-door.js +2 -5
- package/dist/core/runtime/host.d.ts +1 -1
- package/dist/core/runtime/invocation.d.ts +6 -0
- package/dist/core/runtime/invocation.js +10 -0
- package/dist/core/runtime/kickoff.js +34 -0
- package/dist/core/runtime/launch.d.ts +2 -6
- package/dist/core/runtime/node-read.js +4 -1
- package/dist/core/runtime/nodes.js +2 -0
- package/dist/core/runtime/promote.d.ts +1 -0
- package/dist/core/runtime/promote.js +3 -1
- package/dist/core/runtime/session-cycles.d.ts +6 -0
- package/dist/core/runtime/session-cycles.js +15 -1
- package/dist/core/runtime/session-visibility.d.ts +17 -8
- package/dist/core/runtime/session-visibility.js +19 -10
- package/dist/core/runtime/spawn-env.d.ts +9 -1
- package/dist/core/runtime/spawn-env.js +62 -2
- package/dist/core/runtime/stop-guard.d.ts +2 -2
- package/dist/core/runtime/stop-guard.js +2 -2
- package/dist/core/runtime/stop-signals.d.ts +1 -1
- package/dist/core/runtime/stop-signals.js +2 -2
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/core/session-model/session-state.d.ts +4 -3
- package/dist/core/session-model/session-state.js +14 -1
- package/dist/core/user-settings.d.ts +21 -2
- package/dist/core/user-settings.js +32 -11
- package/dist/core/worktree.d.ts +15 -45
- package/dist/core/worktree.js +59 -261
- package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/broker-ops.js +9 -5
- package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
- package/dist/daemon/api/handlers/feedback-comments.js +207 -0
- package/dist/daemon/api/handlers/human.js +24 -20
- package/dist/daemon/api/handlers/inbox.d.ts +10 -0
- package/dist/daemon/api/handlers/inbox.js +39 -115
- package/dist/daemon/api/handlers/profiles.js +11 -29
- package/dist/daemon/api/map.d.ts +6 -1
- package/dist/daemon/api/map.js +23 -0
- package/dist/daemon/api/server.js +3 -1
- package/dist/daemon/companion-retire.d.ts +2 -0
- package/dist/daemon/companion-retire.js +33 -0
- package/dist/daemon/cron-run.js +14 -16
- package/dist/daemon/human/finish.d.ts +12 -7
- package/dist/daemon/human/finish.js +101 -38
- package/dist/daemon/human/sweep.js +5 -1
- package/dist/daemon/profile-delete.d.ts +7 -0
- package/dist/daemon/profile-delete.js +306 -0
- package/dist/daemon/reconcilers/broker-supervision.js +3 -1
- package/dist/daemon/review/deliver.js +3 -3
- package/dist/daemon/review/finish.d.ts +0 -2
- package/dist/daemon/review/finish.js +1 -29
- package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
- package/dist/pi-extensions/canvas-bash-valve.js +19 -5
- package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
- package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
- package/dist/pi-extensions/canvas-review-boundary.js +18 -9
- package/dist/pi-extensions/canvas-stophook.js +4 -4
- package/dist/shared/generated-context.js +2 -2
- package/dist/types.d.ts +44 -2
- package/dist/types.js +7 -2
- package/package.json +4 -4
- package/runtime.lock.json +5 -33
- package/dist/builtin-memory/init.md +0 -38
- package/dist/builtin-memory/plan.md +0 -19
- package/dist/builtin-memory/spec.md +0 -19
- package/dist/commands/human/doc.d.ts +0 -2
- package/dist/commands/human/doc.js +0 -91
- package/dist/commands/human/html.js +0 -81
- package/dist/commands/human/inbox.d.ts +0 -2
- package/dist/core/human/__tests__/html-markdown.test.js +0 -52
- package/dist/core/human/__tests__/page-render.test.js +0 -66
- package/dist/core/human/html-markdown.d.ts +0 -3
- package/dist/core/human/html-markdown.js +0 -288
- package/dist/core/human/page-render.d.ts +0 -4
- package/dist/core/human/page-render.js +0 -105
- package/dist/pages/bundle.css +0 -1
- package/dist/pages/bundle.js +0 -1778
- package/dist/pages/comments.d.ts +0 -72
- package/dist/pages/comments.js +0 -176
- package/dist/pages/controls.d.ts +0 -98
- package/dist/pages/controls.js +0 -261
- package/dist/pages/elements/cards.d.ts +0 -28
- package/dist/pages/elements/cards.js +0 -915
- package/dist/pages/elements/chart.d.ts +0 -23
- package/dist/pages/elements/chart.js +0 -664
- package/dist/pages/elements/options.d.ts +0 -30
- package/dist/pages/elements/options.js +0 -742
- package/dist/pages/elements/pages.d.ts +0 -30
- package/dist/pages/elements/pages.js +0 -377
- package/dist/pages/elements/slot.d.ts +0 -26
- package/dist/pages/elements/slot.js +0 -112
- package/dist/pages/elements/table.d.ts +0 -36
- package/dist/pages/elements/table.js +0 -1043
- package/dist/pages/elements/text.d.ts +0 -31
- package/dist/pages/elements/text.js +0 -1175
- package/dist/pages/entry.d.ts +0 -8
- package/dist/pages/entry.js +0 -8
- package/dist/pages/host.d.ts +0 -134
- package/dist/pages/host.js +0 -165
- package/dist/pages/readonly.d.ts +0 -24
- package/dist/pages/readonly.js +0 -31
- package/dist/pages/register.d.ts +0 -2
- package/dist/pages/register.js +0 -3
- package/dist/pages/responses.d.ts +0 -37
- package/dist/pages/responses.js +0 -56
- package/dist/pages/slot-config.d.ts +0 -36
- package/dist/pages/slot-config.js +0 -50
- package/dist/pages/types.d.ts +0 -98
- package/dist/pages/types.js +0 -23
- /package/dist/{core/human/__tests__/html-markdown.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
- /package/dist/core/{human/__tests__/page-render.test.d.ts → __tests__/plugin-page-components.test.d.ts} +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/core/__tests__/human-node-not-supervised.test.ts
|
|
2
2
|
//
|
|
3
3
|
// BUG REGRESSION (issue #2 — "Headless node can't reach human-ask: human-kind
|
|
4
|
-
// TUI dies at boot"): a headless orchestrator's every `crtr human
|
|
4
|
+
// TUI dies at boot"): a headless orchestrator's every reply-bearing `crtr human send` bridge died
|
|
5
5
|
// at boot with "pi vehicle exited before the session came up".
|
|
6
6
|
//
|
|
7
7
|
// ROOT CAUSE: a kind:'human' row is NOT a broker-hosted agent node — it is a
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/plugin-page-components.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Wiring pins for plugin-contributed page components — the layer whose absence
|
|
4
|
+
// is SILENT: every authoring, validation, daemon, inbox, and TUI consumer takes
|
|
5
|
+
// the catalog as a parameter, so if `resolvePageComponents` stopped reading
|
|
6
|
+
// installed plugins' manifest `page_components` blocks, every plugin-shipped
|
|
7
|
+
// component would simply stop being authorable with all suites green.
|
|
8
|
+
// Plus the archive path: `validatePluginBundle` must carry a valid bundle.json
|
|
9
|
+
// `page_components` block into `ValidatedBundle.page_components` (what the
|
|
10
|
+
// installer copies into the synthesized manifest) and reject an invalid one.
|
|
11
|
+
import { test, before, beforeEach, afterEach, after } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import * as tar from 'tar';
|
|
17
|
+
import { resetScopeCache } from '../scope.js';
|
|
18
|
+
import { resolvePageComponents } from '../config.js';
|
|
19
|
+
import { validatePluginBundle } from '../command-plugins/bundle.js';
|
|
20
|
+
let isolatedHome;
|
|
21
|
+
let isolatedCwd;
|
|
22
|
+
let prevHome;
|
|
23
|
+
let prevCwd;
|
|
24
|
+
before(() => {
|
|
25
|
+
prevHome = process.env.HOME;
|
|
26
|
+
prevCwd = process.cwd();
|
|
27
|
+
});
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-page-components-home-')));
|
|
30
|
+
isolatedCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-page-components-cwd-')));
|
|
31
|
+
process.env.HOME = isolatedHome;
|
|
32
|
+
process.chdir(isolatedCwd);
|
|
33
|
+
resetScopeCache();
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
process.chdir(prevCwd);
|
|
37
|
+
rmSync(isolatedHome, { recursive: true, force: true });
|
|
38
|
+
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
after(() => {
|
|
41
|
+
process.chdir(prevCwd);
|
|
42
|
+
process.env.HOME = prevHome;
|
|
43
|
+
resetScopeCache();
|
|
44
|
+
});
|
|
45
|
+
/** Plant a manifest-only plugin (page components need no commands or executable). */
|
|
46
|
+
function plantPlugin(scopeRootPath, name, pageComponents) {
|
|
47
|
+
const root = join(scopeRootPath, 'plugins', name, '.crouter-plugin');
|
|
48
|
+
mkdirSync(root, { recursive: true });
|
|
49
|
+
writeFileSync(join(root, 'plugin.json'), JSON.stringify({ name, version: '0.1.0', page_components: pageComponents }));
|
|
50
|
+
}
|
|
51
|
+
function userRoot() {
|
|
52
|
+
const root = join(isolatedHome, '.crouter');
|
|
53
|
+
mkdirSync(root, { recursive: true });
|
|
54
|
+
return root;
|
|
55
|
+
}
|
|
56
|
+
test('an enabled user-scope plugin contributes its page components; a disabled one contributes nothing', () => {
|
|
57
|
+
const root = userRoot();
|
|
58
|
+
plantPlugin(root, 'northlight', [
|
|
59
|
+
{ kind: 'connector', description: 'Connector decision', useWhen: 'Use for a connector decision.', doc: 'Product contract.' },
|
|
60
|
+
{ kind: 'applet-card', display: true },
|
|
61
|
+
]);
|
|
62
|
+
plantPlugin(root, 'zzz-disabled', ['ghost']);
|
|
63
|
+
writeFileSync(join(root, 'config.json'), JSON.stringify({ plugins: { 'zzz-disabled': { enabled: false } } }));
|
|
64
|
+
const components = resolvePageComponents();
|
|
65
|
+
assert.deepEqual(components, [
|
|
66
|
+
{ kind: 'connector', description: 'Connector decision', useWhen: 'Use for a connector decision.', doc: 'Product contract.' },
|
|
67
|
+
{ kind: 'applet-card', display: true },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
test('user config entries and plugin contributions resolve together, and a colliding kind is rejected', () => {
|
|
71
|
+
const root = userRoot();
|
|
72
|
+
plantPlugin(root, 'northlight', ['connector']);
|
|
73
|
+
writeFileSync(join(root, 'config.json'), JSON.stringify({ page_components: ['approval'] }));
|
|
74
|
+
assert.deepEqual(resolvePageComponents().map((component) => component.kind), ['approval', 'connector']);
|
|
75
|
+
writeFileSync(join(root, 'config.json'), JSON.stringify({ page_components: ['connector'] }));
|
|
76
|
+
assert.throws(() => resolvePageComponents(), /page component kind "connector" is registered by both config.json and plugin "northlight"/);
|
|
77
|
+
});
|
|
78
|
+
test('two plugins whose kinds derive one JSX tag are rejected, naming both sources', () => {
|
|
79
|
+
const root = userRoot();
|
|
80
|
+
plantPlugin(root, 'alpha', ['foo-bar']);
|
|
81
|
+
plantPlugin(root, 'beta', ['foo--bar']);
|
|
82
|
+
assert.throws(() => resolvePageComponents(), /kind "foo--bar" \(plugin "beta"\) derives JSX tag "FooBar", which duplicates kind "foo-bar" from plugin "alpha"/);
|
|
83
|
+
});
|
|
84
|
+
// --- archive path -----------------------------------------------------------
|
|
85
|
+
function minimalCommandsJson() {
|
|
86
|
+
return {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
mounts: [
|
|
89
|
+
{
|
|
90
|
+
parent: [],
|
|
91
|
+
node: {
|
|
92
|
+
kind: 'branch',
|
|
93
|
+
name: 'app',
|
|
94
|
+
description: 'application lifecycle and inspection',
|
|
95
|
+
whenToUse: 'you need to inspect an application',
|
|
96
|
+
rootEntry: {
|
|
97
|
+
concept: 'applications hosted by a crouter home',
|
|
98
|
+
description: 'application lifecycle and inspection',
|
|
99
|
+
whenToUse: 'you need to inspect an application',
|
|
100
|
+
},
|
|
101
|
+
summary: 'application lifecycle and inspection',
|
|
102
|
+
model: 'Applications are product resources owned by the selected home.',
|
|
103
|
+
children: [
|
|
104
|
+
{
|
|
105
|
+
kind: 'leaf',
|
|
106
|
+
name: 'show',
|
|
107
|
+
description: 'show one application',
|
|
108
|
+
whenToUse: 'inspect a single application by id',
|
|
109
|
+
tier: 'important',
|
|
110
|
+
summary: 'show one application details',
|
|
111
|
+
params: [{ kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' }],
|
|
112
|
+
output: [{ name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' }],
|
|
113
|
+
effects: ['None. Read-only.'],
|
|
114
|
+
rest: { method: 'GET', path: '/v1/apps/{app-id}', params: { 'app-id': { in: 'path' } } },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function makeArchive(bundleJson) {
|
|
123
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-page-components-tar-'));
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(join(dir, 'bundle.json'), JSON.stringify(bundleJson));
|
|
126
|
+
writeFileSync(join(dir, 'commands.json'), JSON.stringify(minimalCommandsJson()));
|
|
127
|
+
const pack = tar.create({ sync: true, cwd: dir, portable: true }, ['bundle.json', 'commands.json']);
|
|
128
|
+
const chunks = [];
|
|
129
|
+
let chunk;
|
|
130
|
+
while ((chunk = pack.read()) !== null)
|
|
131
|
+
chunks.push(chunk);
|
|
132
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
rmSync(dir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
test('validatePluginBundle carries a valid bundle.json page_components block into ValidatedBundle', async () => {
|
|
139
|
+
const archive = makeArchive({
|
|
140
|
+
bundleVersion: 1,
|
|
141
|
+
page_components: ['connector', { kind: 'applet-card', description: 'Applet card', display: true }],
|
|
142
|
+
});
|
|
143
|
+
const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
|
|
144
|
+
assert.deepEqual(validated.issues, []);
|
|
145
|
+
assert.deepEqual(validated.bundle?.page_components, [
|
|
146
|
+
{ kind: 'connector' },
|
|
147
|
+
{ kind: 'applet-card', description: 'Applet card', display: true },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
test('validatePluginBundle rejects an invalid page_components block loudly', async () => {
|
|
151
|
+
const archive = makeArchive({ bundleVersion: 1, page_components: [{ kind: 'connector', use_when: 'typo field' }] });
|
|
152
|
+
const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
|
|
153
|
+
assert.equal(validated.bundle, undefined);
|
|
154
|
+
assert.equal(validated.issues.length, 1);
|
|
155
|
+
assert.match(validated.issues[0].message, /page_components block is invalid/);
|
|
156
|
+
assert.match(validated.issues[0].received, /use_when/);
|
|
157
|
+
});
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// they belong to the compiled seam tier; neither needs tmux.
|
|
20
20
|
import { after, before, test } from 'node:test';
|
|
21
21
|
import assert from 'node:assert/strict';
|
|
22
|
+
import { writeFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
22
24
|
import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
|
|
23
25
|
import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
|
|
24
26
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
@@ -176,6 +178,44 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
176
178
|
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
177
179
|
}
|
|
178
180
|
});
|
|
181
|
+
test('a connected broker viewer keeps an obligation-free resident live', { timeout: 30_000 }, async () => {
|
|
182
|
+
const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
183
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
|
|
184
|
+
const nodeId = h.fabricateBrokerNode({
|
|
185
|
+
id: 'settled-with-viewer',
|
|
186
|
+
parent: root,
|
|
187
|
+
lifecycle: 'resident',
|
|
188
|
+
status: 'idle',
|
|
189
|
+
intent: 'idle-release',
|
|
190
|
+
});
|
|
191
|
+
updateNode(nodeId, {
|
|
192
|
+
launch: {
|
|
193
|
+
extensions: [],
|
|
194
|
+
tools: [],
|
|
195
|
+
systemPrompt: '',
|
|
196
|
+
env: {},
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
reviveNode(nodeId, { resume: true });
|
|
200
|
+
const boot = await h.awaitBoot(nodeId);
|
|
201
|
+
// This is the presence written by a helloed direct attach, which has no
|
|
202
|
+
// focuses-table row because it did not pass through the focus opener.
|
|
203
|
+
writeFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), JSON.stringify({ viewers: 1 }));
|
|
204
|
+
try {
|
|
205
|
+
const seenAt = 15_000;
|
|
206
|
+
await h.tick(seenAt);
|
|
207
|
+
await h.tick(seenAt + 1500);
|
|
208
|
+
assert.equal(h.fleet.has(nodeId), true, 'an attached viewer keeps the broker fleet-owned');
|
|
209
|
+
assert.equal(isPidAlive(boot.pid), true, 'an attached viewer keeps the broker process alive');
|
|
210
|
+
assert.ok(['active', 'idle'].includes(h.node(nodeId)?.status ?? ''), 'an attached viewer keeps the node live');
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
if (priorParkMs === undefined)
|
|
214
|
+
delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
215
|
+
else
|
|
216
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
179
219
|
test('an already idle terminal node with no live obligation reconciles into canceled', async () => {
|
|
180
220
|
const nodeId = h.fabricateBrokerNode({
|
|
181
221
|
id: 'idle-terminal-without-obligation',
|
|
@@ -150,7 +150,7 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
|
|
|
150
150
|
// ('natural stop while awaiting a live worker → idle-release …').
|
|
151
151
|
// ===================================================================
|
|
152
152
|
{
|
|
153
|
-
const stop = evaluateStop(B, { pushedFinal: false,
|
|
153
|
+
const stop = evaluateStop(B, { pushedFinal: false, requestedHumanReply: false });
|
|
154
154
|
assert.equal(stop.action, 'allow', 'awaiting terminal B → allow (legit idle-release)');
|
|
155
155
|
assert.equal(stop.reason, 'awaiting', "B classified awaiting on its ACTIVE live sub to C");
|
|
156
156
|
}
|
|
@@ -225,7 +225,7 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
|
|
|
225
225
|
assert.deepEqual(drift?.from, { mode: 'base', lifecycle: 'terminal' }, 'drift.from = the acked base×terminal');
|
|
226
226
|
assert.deepEqual(drift?.to, { mode: 'orchestrator', lifecycle: 'terminal' }, 'drift.to = the new orchestrator persona');
|
|
227
227
|
assert.match(drift?.guidance ?? '', /ORCHESTRATOR/i, 'the steer carries the base→orchestrator guidance');
|
|
228
|
-
const stop = evaluateStop(B, { pushedFinal: false,
|
|
228
|
+
const stop = evaluateStop(B, { pushedFinal: false, requestedHumanReply: false });
|
|
229
229
|
assert.equal(stop.action, 'reprompt', 'orchestrator B with no live sub, no final → reprompt');
|
|
230
230
|
assert.equal(stop.reason, 'stalled', 'classified stalled');
|
|
231
231
|
assert.equal(stop.action === 'reprompt' ? stop.message : '', STALL_REPROMPT, 'the stall reprompt nudge');
|
|
@@ -11,6 +11,7 @@ import { ApiError, CrtrClient } from '../../../api/index.js';
|
|
|
11
11
|
import { createApiServer } from '../../../daemon/api/server.js';
|
|
12
12
|
import { apiSocketPath } from '../../canvas/paths.js';
|
|
13
13
|
import { claimTicket, readTicketClaim } from '../../human/claim.js';
|
|
14
|
+
import { bindTicketReplyRoute } from '../../human/convention.js';
|
|
14
15
|
import { ticketDir } from '../../human/root.js';
|
|
15
16
|
import { submitPage, submitReview } from '../../human/tickets.js';
|
|
16
17
|
import { spawnNode } from '../../runtime/nodes.js';
|
|
@@ -66,10 +67,14 @@ async function expectReviewSubmitGuidance(nodeId, body) {
|
|
|
66
67
|
function submitBridgePage(bridge) {
|
|
67
68
|
const dir = ticketDir(bridge);
|
|
68
69
|
const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-e2e-page-'));
|
|
69
|
-
const sourceFile = join(sourceDir, 'page.
|
|
70
|
-
writeFileSync(sourceFile, '
|
|
70
|
+
const sourceFile = join(sourceDir, 'page.tsx');
|
|
71
|
+
writeFileSync(sourceFile, 'export default () => (\n <Page title="Ship it?" subtitle="Decide whether to ship now so the release can proceed.">\n <UserText id="question" initialText="" />\n </Page>\n);\n');
|
|
71
72
|
try {
|
|
72
|
-
|
|
73
|
+
bindTicketReplyRoute(dir, bridge);
|
|
74
|
+
// The delivery `human send` derives for a terminal bridge page: a
|
|
75
|
+
// response-bearing slot gives it a reply route, and a terminal sender's page
|
|
76
|
+
// always projects to the inbox.
|
|
77
|
+
submitPage({ dir, sourceFile, delivery: { placement: 'inline', inbox: true, reply: true } });
|
|
73
78
|
}
|
|
74
79
|
finally {
|
|
75
80
|
rmSync(sourceDir, { recursive: true, force: true });
|
|
@@ -100,6 +105,7 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
|
|
|
100
105
|
const reviewDir = ticketDir(reviewBridge);
|
|
101
106
|
const file = join(home, 'review.md');
|
|
102
107
|
writeFileSync(file, '# Review\n');
|
|
108
|
+
bindTicketReplyRoute(reviewDir, reviewBridge);
|
|
103
109
|
submitReview({ dir: reviewDir, review: { title: 'Review', subtitle: 'Review this file so implementation can proceed safely.', source: {}, file } });
|
|
104
110
|
const reviewClaim = claimTicket(reviewDir);
|
|
105
111
|
assert.ok(reviewClaim !== null);
|
|
@@ -108,10 +114,10 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
|
|
|
108
114
|
});
|
|
109
115
|
test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
|
|
110
116
|
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
111
|
-
const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-
|
|
117
|
+
const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
112
118
|
const dir = submitBridgePage(bridge);
|
|
113
|
-
const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true
|
|
114
|
-
assert.deepEqual(result, {
|
|
119
|
+
const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true } } });
|
|
120
|
+
assert.deepEqual(result, { completed: true, reply_delivered: true, kind: 'page' });
|
|
115
121
|
assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by crtrd');
|
|
116
122
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
117
123
|
assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
|
|
@@ -48,7 +48,7 @@ import { evaluateStop } from '../../runtime/stop-guard.js';
|
|
|
48
48
|
function persona(m) {
|
|
49
49
|
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
50
50
|
}
|
|
51
|
-
const SIGNALS = { pushedFinal: false,
|
|
51
|
+
const SIGNALS = { pushedFinal: false, requestedHumanReply: false };
|
|
52
52
|
// ===========================================================================
|
|
53
53
|
// (a) LIFECYCLE FLIP — `crtr node config --lifecycle` on a LIVE node, both directions,
|
|
54
54
|
// observing the idle-release DECISION change. A round-trip on ONE node
|
|
@@ -186,7 +186,7 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
|
|
|
186
186
|
const defaultMenu = tmuxBinding(socket, 'root', 'M-c') ?? '';
|
|
187
187
|
assert.match(defaultMenu, /display-menu/);
|
|
188
188
|
// The inbox toggle installs at its default key (alt+i → M-i) in the same sweep.
|
|
189
|
-
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr
|
|
189
|
+
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr surface inbox/);
|
|
190
190
|
const remapped = installTmuxBindings(resolveKeybindings({
|
|
191
191
|
'crtr.tmux.menu.open': ['alt+shift+z'],
|
|
192
192
|
'crtr.tmux.menu.graph': ['shift+z'],
|
|
@@ -242,11 +242,13 @@ test('tmux installer reads pair-array manifests and replaces owned pairs', (t) =
|
|
|
242
242
|
try {
|
|
243
243
|
// Current-scope releases wrote the pair array before wrappers were added.
|
|
244
244
|
isolatedTmux(socket, ['set-option', '-s', '@crtr_keybindings_manifest', JSON.stringify([{ table: 'root', key: 'M-i' }])]);
|
|
245
|
+
// Seeded with the pre-move command path, so the assertion below distinguishes a
|
|
246
|
+
// genuine replacement from a no-op.
|
|
245
247
|
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-i', 'run-shell', '-b', 'crtr human inbox toggle --quiet']);
|
|
246
248
|
const result = installTmuxBindings(resolveKeybindings());
|
|
247
249
|
assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
|
|
248
250
|
assert.ok(!result.diagnostics.some((d) => d.bindingId === 'crtr.tmux.inbox.toggle'), JSON.stringify(result.diagnostics));
|
|
249
|
-
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr
|
|
251
|
+
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr surface inbox/);
|
|
250
252
|
assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /background running bash/);
|
|
251
253
|
}
|
|
252
254
|
finally {
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { test, before, after, beforeEach } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, mkdirSync, rmSync, existsSync,
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync, statSync, writeFileSync, chmodSync } from 'node:fs';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { gitSync } from '../../git.js';
|
|
8
|
-
import {
|
|
9
|
-
import { createManagedWorktree, closeManagedWorktree, managedWorktreePath, rollbackManagedWorktree, setWorktreeCleanupRunnerForTest } from '../../worktree.js';
|
|
8
|
+
import { createManagedWorktree, closeManagedWorktree, managedWorktreePath, rollbackManagedWorktree } from '../../worktree.js';
|
|
10
9
|
import { createNode, getNode } from '../../canvas/canvas.js';
|
|
11
10
|
import { closeDb } from '../../canvas/db.js';
|
|
12
11
|
let home;
|
|
@@ -60,14 +59,9 @@ after(() => {
|
|
|
60
59
|
rmSync(sourceDir, { recursive: true, force: true });
|
|
61
60
|
delete process.env['CRTR_HOME'];
|
|
62
61
|
});
|
|
63
|
-
// Regression for #
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
// dir is gone every subsequent bash in that node fails permanently and the
|
|
67
|
-
// mandated post-close `crtr push final` can never run. closeManagedWorktree
|
|
68
|
-
// must leave the path resolvable (as an empty tombstone dir) even though the
|
|
69
|
-
// git worktree registration itself is gone.
|
|
70
|
-
test('closeManagedWorktree recreates the worktree path as a tombstone dir so the node cwd still resolves', () => {
|
|
62
|
+
// Regression for #710: close lands from a live caller, so it must preserve the
|
|
63
|
+
// caller checkout rather than starting Git's recursive removal beneath it.
|
|
64
|
+
test('closeManagedWorktree leaves the active caller checkout intact with named deferred cleanup', () => {
|
|
71
65
|
const nodeId = 'wt-close-1';
|
|
72
66
|
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
73
67
|
assert.equal(wt.base_ref, 'main', 'default base must remain backward-compatible with persisted main worktrees');
|
|
@@ -75,22 +69,16 @@ test('closeManagedWorktree recreates the worktree path as a tombstone dir so the
|
|
|
75
69
|
assert.ok(existsSync(wt.path), 'worktree checkout should exist after create');
|
|
76
70
|
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
77
71
|
const result = closeManagedWorktree(nodeId);
|
|
78
|
-
assert.equal(result.worktree_removed,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
assert.ok(
|
|
82
|
-
assert.ok(statSync(wt.path).isDirectory(), 'recreated path must be a directory');
|
|
83
|
-
assert.deepEqual(readdirSync(wt.path), [], 'tombstone dir should be empty — git already removed the real checkout');
|
|
84
|
-
// git itself no longer tracks it as a worktree.
|
|
72
|
+
assert.equal(result.worktree_removed, false);
|
|
73
|
+
assert.match(result.worktree_remove_error ?? '', /cleanup pending/);
|
|
74
|
+
assert.ok(existsSync(join(wt.path, 'README.md')), 'the caller checkout keeps its tracked files');
|
|
75
|
+
assert.ok(statSync(wt.path).isDirectory(), 'the caller cwd remains a directory');
|
|
85
76
|
const list = gitSync(['worktree', 'list', '--porcelain'], sourceDir);
|
|
86
|
-
assert.ok(
|
|
77
|
+
assert.ok(list.stdout.includes(wt.path), 'Git keeps the intact caller checkout registered');
|
|
87
78
|
const meta = getNode(nodeId);
|
|
88
79
|
assert.equal(meta?.managed_worktree?.state, 'closed');
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// reapable by concurrent git GC, and not a real repo checkout even while
|
|
92
|
-
// it survives). closeManagedWorktree must repoint the persisted cwd.
|
|
93
|
-
assert.equal(meta?.cwd, wt.repo_root, 'closed node cwd must repoint to the repo root, not the removed worktree path');
|
|
80
|
+
assert.equal(meta?.managed_worktree?.cleanup, 'pending');
|
|
81
|
+
assert.equal(meta?.cwd, wt.repo_root, 'a later revive uses the durable base checkout');
|
|
94
82
|
});
|
|
95
83
|
test('two concurrent creates from linked worktrees serialize on their shared repository', async () => {
|
|
96
84
|
const worktreeModule = new URL('../../worktree.ts', import.meta.url).href;
|
|
@@ -372,7 +360,8 @@ test('close ignores crtr\'s own untracked .crouter/config.json and state.json bo
|
|
|
372
360
|
// reports a clean tree, not just that close happens not to notice.
|
|
373
361
|
assert.equal(gitSync(['status', '--porcelain'], wt.path).stdout.trim(), '', 'git status must already be clean with only crtr bookkeeping present');
|
|
374
362
|
const result = closeManagedWorktree(nodeId);
|
|
375
|
-
assert.equal(result.worktree_removed,
|
|
363
|
+
assert.equal(result.worktree_removed, false);
|
|
364
|
+
assert.match(result.worktree_remove_error ?? '', /cleanup pending/);
|
|
376
365
|
});
|
|
377
366
|
// The exclude must be narrow: it must NEVER hide real, committable node work
|
|
378
367
|
// that merely happens to live under `.crouter/` — this mirrors crouter's OWN
|
|
@@ -416,21 +405,15 @@ test('autoDropCleanManagedWorktree drops a clean, zero-commit worktree', async (
|
|
|
416
405
|
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
417
406
|
const result = autoDropCleanManagedWorktree(nodeId);
|
|
418
407
|
assert.ok(result !== null, 'a worktree with zero commits and a clean tree must be droppable');
|
|
419
|
-
assert.equal(result?.worktree_removed,
|
|
408
|
+
assert.equal(result?.worktree_removed, false);
|
|
409
|
+
assert.match(result?.worktree_remove_error ?? '', /cleanup pending/);
|
|
420
410
|
const meta = getNode(nodeId);
|
|
421
411
|
assert.equal(meta?.managed_worktree?.state, 'closed');
|
|
422
|
-
assert.
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// empty tombstone dir (see `bestEffortRemoveWorktreeCheckout`), so a later
|
|
428
|
-
// `--reopen` revive's `existsSync(meta.cwd)` preflight passed anyway and
|
|
429
|
-
// launched into a non-checkout instead of failing loud or landing somewhere
|
|
430
|
-
// real. `cwd` must be durable: a real git checkout, not a reapable stand-in.
|
|
431
|
-
assert.equal(meta?.cwd, wt.repo_root, 'auto-dropped node cwd must repoint to the repo root, not the removed worktree path');
|
|
432
|
-
assert.equal(gitSync(['rev-parse', '--git-dir'], meta.cwd).status, 0, 'repointed cwd must resolve to a real git checkout a reopen/revive can launch from');
|
|
433
|
-
assert.equal(existsSync(join(wt.path, '.git')), false, 'the tombstoned worktree path must NOT itself resolve as a git checkout');
|
|
412
|
+
assert.equal(meta?.managed_worktree?.cleanup, 'pending');
|
|
413
|
+
assert.ok(gitSync(['worktree', 'list', '--porcelain'], sourceDir).stdout.includes(wt.path), 'Git keeps the caller checkout registered until it exits');
|
|
414
|
+
assert.equal(meta?.cwd, wt.repo_root, 'auto-dropped node cwd repoints to the durable base checkout');
|
|
415
|
+
assert.equal(gitSync(['rev-parse', '--git-dir'], meta.cwd).status, 0, 'repointed cwd resolves to a real git checkout');
|
|
416
|
+
assert.ok(existsSync(join(wt.path, '.git')), 'the intact caller worktree remains a git checkout');
|
|
434
417
|
});
|
|
435
418
|
// The same real-commit case close already protects: a worktree with actual
|
|
436
419
|
// unlanded work must NEVER be auto-dropped by push-final.
|
|
@@ -570,9 +553,10 @@ test('autoDropCleanManagedWorktree drops a branch with real commits already cont
|
|
|
570
553
|
assert.equal(gitSync(['rev-parse', 'main'], repo).stdout.trim(), branchSha);
|
|
571
554
|
const result = autoDropCleanManagedWorktree(nodeId);
|
|
572
555
|
assert.ok(result !== null, 'a branch whose commits are already contained in a moved base has nothing left to land');
|
|
573
|
-
assert.equal(result?.branch_deleted,
|
|
556
|
+
assert.equal(result?.branch_deleted, false);
|
|
574
557
|
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'closed');
|
|
575
|
-
assert.equal(
|
|
558
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.cleanup, 'pending');
|
|
559
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], repo).status, 0, 'the caller branch remains checked out until cleanup runs after exit');
|
|
576
560
|
// The work itself is NOT lost — it stays reachable from main, which is the point.
|
|
577
561
|
assert.equal(gitSync(['merge-base', '--is-ancestor', branchSha, 'main'], repo).status, 0, 'the landed commit must remain reachable from main after the drop');
|
|
578
562
|
rmSync(repo, { recursive: true, force: true });
|
|
@@ -687,63 +671,6 @@ test('the re-verified base containment refuses when the base is rewritten betwee
|
|
|
687
671
|
// for the branch-only race, and the same one the independent re-review used
|
|
688
672
|
// to reproduce this exact Critical against `6de1c8d`.
|
|
689
673
|
// ---------------------------------------------------------------------------
|
|
690
|
-
test('auto-drop atomic delete refuses when the base is force-reset between the live ancestry check and the ref transaction', async () => {
|
|
691
|
-
const { proveCleanEmptyManagedWorktree, autoDropCleanManagedWorktreeLocked } = await import('../../worktree.js');
|
|
692
|
-
const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-auto-drop-base-window-'));
|
|
693
|
-
run(['init', '-b', 'main'], repo);
|
|
694
|
-
run(['config', 'user.email', 'test@example.com'], repo);
|
|
695
|
-
run(['config', 'user.name', 'Test'], repo);
|
|
696
|
-
writeFileSync(join(repo, 'README.md'), 'hello\n');
|
|
697
|
-
run(['add', '.'], repo);
|
|
698
|
-
run(['commit', '-m', 'init'], repo);
|
|
699
|
-
const preMergeMainSha = gitSync(['rev-parse', 'main'], repo).stdout.trim();
|
|
700
|
-
const nodeId = 'wt-auto-drop-base-window';
|
|
701
|
-
const wt = createManagedWorktree(repo, nodeId);
|
|
702
|
-
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
703
|
-
writeFileSync(join(wt.path, 'feature.txt'), 'landed elsewhere\n');
|
|
704
|
-
run(['add', '.'], wt.path);
|
|
705
|
-
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'feature'], wt.path);
|
|
706
|
-
const branchSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
707
|
-
// A manager fast-forwards main past this branch's own tip, exactly like
|
|
708
|
-
// the #327 positive case, so the branch proves clean-and-contained.
|
|
709
|
-
run(['merge', '--ff-only', branchSha], repo);
|
|
710
|
-
assert.equal(gitSync(['rev-parse', 'main'], repo).stdout.trim(), branchSha);
|
|
711
|
-
const proof = proveCleanEmptyManagedWorktree(wt);
|
|
712
|
-
assert.ok(proof !== null, 'the branch, now contained in the moved base, must prove droppable');
|
|
713
|
-
// Wrap `git worktree remove` — the call `autoDropCleanManagedWorktreeLocked`
|
|
714
|
-
// makes strictly AFTER its own live ancestry re-check and strictly BEFORE
|
|
715
|
-
// its ref-deletion transaction — to force-reset main back to its pre-merge
|
|
716
|
-
// tip right before the real removal runs. A sequential re-check placed
|
|
717
|
-
// anywhere before this point cannot see this rewrite; only binding the
|
|
718
|
-
// base's live value into the delete transaction itself can.
|
|
719
|
-
const wrapperDir = mkdtempSync(join(tmpdir(), 'crtr-git-wrapper-'));
|
|
720
|
-
const wrapperPath = join(wrapperDir, 'git');
|
|
721
|
-
const originalPath = process.env['PATH'] ?? '';
|
|
722
|
-
writeFileSync(wrapperPath, `#!/bin/sh
|
|
723
|
-
if [ "$1" = "worktree" ] && [ "$2" = "remove" ] && [ "$3" = "${wt.path}" ]; then
|
|
724
|
-
PATH="$CRTR_TEST_REAL_PATH" git -C "${repo}" update-ref refs/heads/main "${preMergeMainSha}"
|
|
725
|
-
fi
|
|
726
|
-
PATH="$CRTR_TEST_REAL_PATH" exec git "$@"
|
|
727
|
-
`);
|
|
728
|
-
chmodSync(wrapperPath, 0o755);
|
|
729
|
-
process.env['CRTR_TEST_REAL_PATH'] = originalPath;
|
|
730
|
-
process.env['PATH'] = `${wrapperDir}:${originalPath}`;
|
|
731
|
-
let result;
|
|
732
|
-
try {
|
|
733
|
-
result = autoDropCleanManagedWorktreeLocked(nodeId, wt, proof);
|
|
734
|
-
}
|
|
735
|
-
finally {
|
|
736
|
-
process.env['PATH'] = originalPath;
|
|
737
|
-
delete process.env['CRTR_TEST_REAL_PATH'];
|
|
738
|
-
rmSync(wrapperDir, { recursive: true, force: true });
|
|
739
|
-
}
|
|
740
|
-
assert.equal(result, null, 'a base rewritten strictly after the live ancestry check must still refuse the atomic delete');
|
|
741
|
-
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'state must stay open — nothing may be marked safely dropped');
|
|
742
|
-
const refCheck = gitSync(['rev-parse', '--verify', `refs/heads/${wt.branch}`], repo);
|
|
743
|
-
assert.equal(refCheck.status, 0, 'the source branch must be preserved');
|
|
744
|
-
assert.equal(refCheck.stdout.trim(), branchSha, 'the branch must still name the proven commit — the source commit remains reachable from this head');
|
|
745
|
-
rmSync(repo, { recursive: true, force: true });
|
|
746
|
-
});
|
|
747
674
|
test('close-landed atomic delete refuses when the base is force-reset between landing and the cleanup delete', () => {
|
|
748
675
|
const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-close-base-window-'));
|
|
749
676
|
run(['init', '-b', 'main'], repo);
|
|
@@ -817,107 +744,6 @@ test('auto-drop refuses and preserves the branch and checkout when checkout remo
|
|
|
817
744
|
assert.ok(list.stdout.includes(wt.path), 'the checkout must remain registered and recoverable');
|
|
818
745
|
assert.ok(existsSync(join(wt.path, 'late-arrival.txt')), 'the live checkout, including its uncommitted file, must remain intact');
|
|
819
746
|
});
|
|
820
|
-
test('post-land cleanup timeout preserves the land and releases the repository lock', async () => {
|
|
821
|
-
const nodeId = 'wt-close-cleanup-timeout';
|
|
822
|
-
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
823
|
-
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
824
|
-
writeFileSync(join(wt.path, 'cleanup-timeout-work.txt'), 'work\n');
|
|
825
|
-
run(['add', '.'], wt.path);
|
|
826
|
-
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'cleanup timeout work'], wt.path);
|
|
827
|
-
const baseBefore = gitSync(['rev-parse', 'main'], sourceDir).stdout.trim();
|
|
828
|
-
setWorktreeCleanupRunnerForTest((args, cwd, options) => {
|
|
829
|
-
if (args[0] === 'worktree' && args[1] === 'remove' && args[2] === wt.path) {
|
|
830
|
-
assert.equal(options.timeoutMs, 5_000, 'only post-land cleanup gets the finite deadline');
|
|
831
|
-
return { status: 1, stdout: '', stderr: '', timedOut: true };
|
|
832
|
-
}
|
|
833
|
-
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
834
|
-
assert.equal(options.timeoutMs, 5_000, 'residual inspection gets the same finite deadline');
|
|
835
|
-
const inspection = gitSync(args, cwd, undefined, options);
|
|
836
|
-
return {
|
|
837
|
-
...inspection,
|
|
838
|
-
// Branch identity is not checkout-validity: a switch/detach racing the
|
|
839
|
-
// timed-out remove still leaves a live registration that needs --force.
|
|
840
|
-
stdout: inspection.stdout.replace(`branch refs/heads/${wt.branch}`, 'branch refs/heads/externally-switched'),
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
|
-
return gitSync(args, cwd, undefined, options);
|
|
844
|
-
});
|
|
845
|
-
try {
|
|
846
|
-
const started = Date.now();
|
|
847
|
-
const result = closeManagedWorktree(nodeId);
|
|
848
|
-
const elapsed = Date.now() - started;
|
|
849
|
-
const cleanupDeadlineMs = 5_000;
|
|
850
|
-
const schedulingMarginMs = 500;
|
|
851
|
-
assert.ok(elapsed < cleanupDeadlineMs - schedulingMarginMs, `cleanup timeout must return promptly, took ${elapsed}ms`);
|
|
852
|
-
assert.notEqual(result.landed_sha, baseBefore, 'the land must contain a new commit');
|
|
853
|
-
assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), result.landed_sha, 'the base must contain the landed commit');
|
|
854
|
-
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'closed', 'timeout must preserve the closed state');
|
|
855
|
-
assert.equal(result.worktree_removed, false);
|
|
856
|
-
assert.equal(result.branch_deleted, false);
|
|
857
|
-
assert.match(result.worktree_remove_error ?? '', /timed out/);
|
|
858
|
-
assert.match(result.worktree_remove_error ?? '', /git -C .* worktree remove --force/);
|
|
859
|
-
assert.doesNotMatch(result.worktree_remove_error ?? '', /worktree prune/, 'a valid registered checkout needs force-removal even if its branch changed');
|
|
860
|
-
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 0, 'the source branch must remain reachable');
|
|
861
|
-
// The node that timed out must still be able to complete its own finalization
|
|
862
|
-
// from the recreated cwd; a different-node lock check alone is insufficient.
|
|
863
|
-
await push(nodeId, { kind: 'final', body: 'timed-out cleanup node finalized' });
|
|
864
|
-
assert.equal(getNode(nodeId)?.status, 'done', 'the timed-out node must finalize itself successfully');
|
|
865
|
-
assert.ok(getNode(nodeId)?.final_report, 'the timed-out node must record its canonical final');
|
|
866
|
-
// A second close must acquire the same repository lock and complete, proving
|
|
867
|
-
// the timed-out cleanup did not strand the lock around the finished land.
|
|
868
|
-
const followupId = 'wt-close-after-cleanup-timeout';
|
|
869
|
-
const followup = createManagedWorktree(sourceDir, followupId);
|
|
870
|
-
createNode(node(followupId, { cwd: followup.path, managed_worktree: followup }));
|
|
871
|
-
writeFileSync(join(followup.path, 'followup-work.txt'), 'followup\n');
|
|
872
|
-
run(['add', '.'], followup.path);
|
|
873
|
-
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'followup work'], followup.path);
|
|
874
|
-
const followupResult = closeManagedWorktree(followupId);
|
|
875
|
-
assert.equal(followupResult.worktree_removed, true, 'subsequent finalization must complete');
|
|
876
|
-
assert.equal(getNode(followupId)?.managed_worktree?.state, 'closed');
|
|
877
|
-
}
|
|
878
|
-
finally {
|
|
879
|
-
setWorktreeCleanupRunnerForTest();
|
|
880
|
-
}
|
|
881
|
-
});
|
|
882
|
-
test('post-land cleanup timeout gives prune guidance for a tombstone residual', () => {
|
|
883
|
-
const nodeId = 'wt-close-cleanup-timeout-tombstone';
|
|
884
|
-
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
885
|
-
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
886
|
-
writeFileSync(join(wt.path, 'cleanup-tombstone-work.txt'), 'work\n');
|
|
887
|
-
run(['add', '.'], wt.path);
|
|
888
|
-
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'cleanup tombstone work'], wt.path);
|
|
889
|
-
setWorktreeCleanupRunnerForTest((args, cwd, options) => {
|
|
890
|
-
if (args[0] === 'worktree' && args[1] === 'remove' && args[2] === wt.path) {
|
|
891
|
-
assert.equal(options.timeoutMs, 5_000, 'the timed-out remove gets the finite deadline');
|
|
892
|
-
rmSync(wt.path, { recursive: true, force: true });
|
|
893
|
-
mkdirSync(wt.path, { recursive: true });
|
|
894
|
-
return { status: 1, stdout: '', stderr: '', timedOut: true };
|
|
895
|
-
}
|
|
896
|
-
if (args[0] === 'worktree' && args[1] === 'list') {
|
|
897
|
-
assert.equal(options.timeoutMs, 5_000, 'residual inspection gets the same finite deadline');
|
|
898
|
-
const branchSha = gitSync(['rev-parse', `refs/heads/${wt.branch}`], sourceDir).stdout.trim();
|
|
899
|
-
return {
|
|
900
|
-
status: 0,
|
|
901
|
-
stdout: `worktree ${wt.path}\nHEAD ${branchSha}\nbranch refs/heads/${wt.branch}\nprunable gitdir file points to non-existent location\n`,
|
|
902
|
-
stderr: '',
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
return gitSync(args, cwd, undefined, options);
|
|
906
|
-
});
|
|
907
|
-
try {
|
|
908
|
-
const result = closeManagedWorktree(nodeId);
|
|
909
|
-
const guidance = result.worktree_remove_error ?? '';
|
|
910
|
-
assert.equal(result.worktree_removed, false);
|
|
911
|
-
assert.equal(result.branch_deleted, false);
|
|
912
|
-
assert.match(guidance, /git -C .* worktree prune/);
|
|
913
|
-
assert.match(guidance, /rm -rf/);
|
|
914
|
-
assert.doesNotMatch(guidance, /worktree remove --force/);
|
|
915
|
-
assert.ok(existsSync(wt.path), 'the tombstone residual must remain available for guided cleanup');
|
|
916
|
-
}
|
|
917
|
-
finally {
|
|
918
|
-
setWorktreeCleanupRunnerForTest();
|
|
919
|
-
}
|
|
920
|
-
});
|
|
921
747
|
test('close-landed never low-level-deletes the source branch when checkout removal is refused', () => {
|
|
922
748
|
const nodeId = 'wt-close-removal-refused';
|
|
923
749
|
const wt = createManagedWorktree(sourceDir, nodeId);
|