@north-light/crouter 0.3.198 → 0.3.200
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__/helpers/harness.js +17 -55
- 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/broker-crash-teardown.test.js +4 -5
- 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 +6 -5
- 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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Compiled API seam for the profile-deletion lifecycle. It drives the real CLI
|
|
2
|
+
// and daemon HTTP handlers against one isolated canvas, including real broker
|
|
3
|
+
// processes for detach/restart and deferred self-deletion behavior.
|
|
4
|
+
import { request as httpRequest } from 'node:http';
|
|
5
|
+
import { after, before, test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { ApiError, CrtrClient } from '../../../../api/index.js';
|
|
10
|
+
import { createHeadlessHarness } from '../../../../core/__tests__/helpers/harness.js';
|
|
11
|
+
import { updateNode } from '../../../../core/canvas/canvas.js';
|
|
12
|
+
import { armCron, getCron } from '../../../../core/canvas/crons.js';
|
|
13
|
+
import { closeDb } from '../../../../core/canvas/db.js';
|
|
14
|
+
import { getDefaultProfileId, setDefaultProfileId, } from '../../../../core/profiles/default-binding.js';
|
|
15
|
+
import { loadProfileManifest } from '../../../../core/profiles/manifest.js';
|
|
16
|
+
import { createComment, getComment, listReviewEvents } from '../../../../core/review/comments.js';
|
|
17
|
+
import { commitBindingReview, getReview } from '../../../../core/review/store.js';
|
|
18
|
+
import { apiSocketPath, nodeDir, nodesRoot, reviewDir, } from '../../../../core/canvas/paths.js';
|
|
19
|
+
import { isPidAlive } from '../../../../core/canvas/pid.js';
|
|
20
|
+
import { spawnNode } from '../../../../core/runtime/nodes.js';
|
|
21
|
+
let h;
|
|
22
|
+
let client;
|
|
23
|
+
before(async () => {
|
|
24
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-delete' });
|
|
25
|
+
client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
|
|
26
|
+
});
|
|
27
|
+
after(async () => {
|
|
28
|
+
await h?.dispose();
|
|
29
|
+
});
|
|
30
|
+
function managedWorktree() {
|
|
31
|
+
return {
|
|
32
|
+
state: 'open',
|
|
33
|
+
path: join(h.agentHome, 'open-worktree'),
|
|
34
|
+
branch: 'test/profile-delete-blocker',
|
|
35
|
+
repo_root: process.cwd(),
|
|
36
|
+
base_ref: 'HEAD',
|
|
37
|
+
base_sha: '0000000000000000000000000000000000000000',
|
|
38
|
+
created: new Date().toISOString(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function armTestCron(args) {
|
|
42
|
+
armCron({
|
|
43
|
+
cron_id: args.id,
|
|
44
|
+
name: args.id,
|
|
45
|
+
created_by: null,
|
|
46
|
+
command: 'true',
|
|
47
|
+
fire_at: '2099-01-01T00:00:00.000Z',
|
|
48
|
+
recur: null,
|
|
49
|
+
tz: null,
|
|
50
|
+
expires_at: null,
|
|
51
|
+
anchor_node: args.anchor ?? null,
|
|
52
|
+
cancel_on_wake: false,
|
|
53
|
+
cwd: process.cwd(),
|
|
54
|
+
env_json: null,
|
|
55
|
+
profile: args.profile,
|
|
56
|
+
scope: 'profile',
|
|
57
|
+
run_timeout_s: 30,
|
|
58
|
+
overlap: 'skip',
|
|
59
|
+
on_output: 'silent',
|
|
60
|
+
sink: '{}',
|
|
61
|
+
tier: 'normal',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function seedTicket(dir, sourceNodeId) {
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
writeFileSync(join(dir, 'page.json'), JSON.stringify({ source: { nodeId: sourceNodeId } }));
|
|
67
|
+
writeFileSync(join(dir, 'reply-route.json'), JSON.stringify({ nodeId: sourceNodeId }));
|
|
68
|
+
}
|
|
69
|
+
function reviewRow(reviewId, originNodeId, sourceFile) {
|
|
70
|
+
const dir = reviewDir(reviewId);
|
|
71
|
+
return {
|
|
72
|
+
review_id: reviewId,
|
|
73
|
+
idempotency_key: `request-${reviewId}`,
|
|
74
|
+
origin_kind: 'inline',
|
|
75
|
+
origin_node_id: originNodeId,
|
|
76
|
+
bridge_node_id: null,
|
|
77
|
+
companion_node_id: `companion-${reviewId}`,
|
|
78
|
+
review_dir: dir,
|
|
79
|
+
file: sourceFile,
|
|
80
|
+
origin_session_file: join(h.agentHome, `${reviewId}-origin.jsonl`),
|
|
81
|
+
origin_session_id: `session-${reviewId}`,
|
|
82
|
+
origin_leaf_id: `leaf-${reviewId}`,
|
|
83
|
+
model_spec: 'anthropic/sonnet',
|
|
84
|
+
branch_file: join(dir, 'branch-point.jsonl'),
|
|
85
|
+
baseline_sha256: 'baseline',
|
|
86
|
+
baseline_bytes: 6,
|
|
87
|
+
anchor_base_sha256: 'anchor',
|
|
88
|
+
anchor_base_bytes: Buffer.from('source'),
|
|
89
|
+
state: 'binding',
|
|
90
|
+
created: new Date().toISOString(),
|
|
91
|
+
captured_at: new Date().toISOString(),
|
|
92
|
+
opened_at: null,
|
|
93
|
+
submit_requested_at: null,
|
|
94
|
+
output_path: null,
|
|
95
|
+
subtitle: null,
|
|
96
|
+
approved_at: null,
|
|
97
|
+
approved_sha256: null,
|
|
98
|
+
changed: null,
|
|
99
|
+
comments_total: null,
|
|
100
|
+
comments_unresolved: null,
|
|
101
|
+
result_json: null,
|
|
102
|
+
canceled_at: null,
|
|
103
|
+
cancel_reason: null,
|
|
104
|
+
cancel_actor: null,
|
|
105
|
+
delivery_entry_id: null,
|
|
106
|
+
delivered_at: null,
|
|
107
|
+
delivery_error: null,
|
|
108
|
+
projected_at: null,
|
|
109
|
+
projection_error: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function seedReview(reviewId, originNodeId) {
|
|
113
|
+
const sourceFile = join(h.agentHome, `${reviewId}-source.md`);
|
|
114
|
+
const dir = reviewDir(reviewId);
|
|
115
|
+
writeFileSync(sourceFile, 'source\n');
|
|
116
|
+
mkdirSync(dir, { recursive: true });
|
|
117
|
+
writeFileSync(join(dir, 'branch-point.jsonl'), '{}\n');
|
|
118
|
+
commitBindingReview(reviewRow(reviewId, originNodeId, sourceFile));
|
|
119
|
+
const { comment } = createComment({
|
|
120
|
+
review_id: reviewId,
|
|
121
|
+
text: 'keep or delete with its review',
|
|
122
|
+
anchor: { line: 1, end_line: 1 },
|
|
123
|
+
line_text: 'source',
|
|
124
|
+
doc_hash: 'baseline',
|
|
125
|
+
actor: { kind: 'node', node_id: originNodeId },
|
|
126
|
+
operation_id: `comment-${reviewId}`,
|
|
127
|
+
});
|
|
128
|
+
return { sourceFile, commentId: comment.comment_id };
|
|
129
|
+
}
|
|
130
|
+
async function rawDelete(profile, body) {
|
|
131
|
+
const payload = JSON.stringify(body);
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const req = httpRequest({
|
|
134
|
+
socketPath: apiSocketPath(),
|
|
135
|
+
method: 'DELETE',
|
|
136
|
+
path: `/v1/profiles/${encodeURIComponent(profile)}`,
|
|
137
|
+
headers: {
|
|
138
|
+
'content-type': 'application/json',
|
|
139
|
+
'content-length': Buffer.byteLength(payload),
|
|
140
|
+
},
|
|
141
|
+
}, (res) => {
|
|
142
|
+
const chunks = [];
|
|
143
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
144
|
+
res.on('end', () => {
|
|
145
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
146
|
+
resolve({
|
|
147
|
+
status: res.statusCode ?? 0,
|
|
148
|
+
body: text === '' ? {} : JSON.parse(text),
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
req.on('error', reject);
|
|
153
|
+
req.end(payload);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
test('forced profile deletion owns the complete daemon lifecycle', { timeout: 90_000 }, async () => {
|
|
157
|
+
// Required confirmation is enforced by both the command parser and the API.
|
|
158
|
+
const forceProfile = await client.ensureProfile('delete-force-required');
|
|
159
|
+
const help = h.cli(null, ['profile', 'delete', '-h']);
|
|
160
|
+
assert.equal(help.code, 0);
|
|
161
|
+
assert.match(help.stdout, /--force\s+required boolean/, 'leaf help exposes --force as required');
|
|
162
|
+
const missingForce = h.cli(null, ['profile', 'delete', forceProfile.id]);
|
|
163
|
+
assert.notEqual(missingForce.code, 0, 'the CLI refuses deletion without --force');
|
|
164
|
+
assert.match(`${missingForce.stdout}\n${missingForce.stderr}`, /force/i);
|
|
165
|
+
await assert.rejects(client.deleteProfile(forceProfile.id, { force: false }), (error) => error instanceof ApiError && error.status === 400 && error.code === 'usage', 'the daemon independently rejects a request without force=true');
|
|
166
|
+
assert.equal((await client.getProfile(forceProfile.id)).id, forceProfile.id, 'force rejection does not remove the profile');
|
|
167
|
+
// An open managed worktree blocks before any profile-owned state mutates.
|
|
168
|
+
const blockedProfile = await client.ensureProfile('delete-blocked');
|
|
169
|
+
const blockedNode = h.spawnRoot('profile delete blocker', { id: 'blocked-profile-deadbeef' });
|
|
170
|
+
updateNode(blockedNode, {
|
|
171
|
+
profile_id: blockedProfile.id,
|
|
172
|
+
managed_worktree: managedWorktree(),
|
|
173
|
+
});
|
|
174
|
+
const blockedPin = join(h.agentHome, 'blocked-pin');
|
|
175
|
+
mkdirSync(blockedPin, { recursive: true });
|
|
176
|
+
setDefaultProfileId(blockedPin, blockedProfile.id);
|
|
177
|
+
armTestCron({ id: 'blocked-cron', profile: blockedProfile.id, anchor: blockedNode });
|
|
178
|
+
await assert.rejects(client.deleteProfile(blockedProfile.id, { force: true }), (error) => {
|
|
179
|
+
if (!(error instanceof ApiError) || error.status !== 400 || error.code !== 'usage')
|
|
180
|
+
return false;
|
|
181
|
+
const details = error.details;
|
|
182
|
+
return details?.error === 'open_managed_worktree';
|
|
183
|
+
}, 'an open managed worktree round-trips the blocker without mutation');
|
|
184
|
+
closeDb();
|
|
185
|
+
assert.equal(h.node(blockedNode)?.profile_id, blockedProfile.id);
|
|
186
|
+
assert.equal(getCron('blocked-cron')?.profile, blockedProfile.id);
|
|
187
|
+
assert.equal(getDefaultProfileId(blockedPin), blockedProfile.id);
|
|
188
|
+
assert.equal((await client.getProfile(blockedProfile.id)).id, blockedProfile.id);
|
|
189
|
+
// Hard deletion selects by profile identity, not by graph descent. It removes
|
|
190
|
+
// matching nodes plus profile/anchor crons, originating tickets and review
|
|
191
|
+
// audit state, while preserving a cross-profile child and the reviewed file.
|
|
192
|
+
const hardProfile = await client.ensureProfile('delete-hard');
|
|
193
|
+
const crossProfile = await client.ensureProfile('delete-cross-profile');
|
|
194
|
+
const hardNode = h.fabricateBrokerNode({
|
|
195
|
+
id: 'hard-owner-deadbeef',
|
|
196
|
+
parent: null,
|
|
197
|
+
status: 'done',
|
|
198
|
+
});
|
|
199
|
+
updateNode(hardNode, { profile_id: hardProfile.id });
|
|
200
|
+
const crossChild = h.fabricateBrokerNode({
|
|
201
|
+
id: 'cross-child-deadbeef',
|
|
202
|
+
parent: hardNode,
|
|
203
|
+
status: 'done',
|
|
204
|
+
});
|
|
205
|
+
updateNode(crossChild, { profile_id: crossProfile.id });
|
|
206
|
+
armTestCron({ id: 'hard-profile-cron', profile: hardProfile.id });
|
|
207
|
+
armTestCron({ id: 'hard-anchor-cron', profile: crossProfile.id, anchor: hardNode });
|
|
208
|
+
armTestCron({ id: 'cross-preserved-cron', profile: crossProfile.id, anchor: crossChild });
|
|
209
|
+
const hardPin = join(h.agentHome, 'hard-pin');
|
|
210
|
+
mkdirSync(hardPin, { recursive: true });
|
|
211
|
+
setDefaultProfileId(hardPin, hardProfile.id);
|
|
212
|
+
const standaloneTicket = join(nodesRoot(), 'hard-ticket-deadbeef');
|
|
213
|
+
seedTicket(standaloneTicket, hardNode);
|
|
214
|
+
seedTicket(nodeDir(crossChild), hardNode);
|
|
215
|
+
writeFileSync(join(nodeDir(crossChild), 'feedback.json'), '{}\n');
|
|
216
|
+
mkdirSync(join(nodeDir(crossChild), '.ticket-lock'));
|
|
217
|
+
writeFileSync(join(nodeDir(crossChild), 'cross-profile-state.txt'), 'preserve');
|
|
218
|
+
const hardReviewId = 'hard-review-deadbeef';
|
|
219
|
+
const hardReview = seedReview(hardReviewId, hardNode);
|
|
220
|
+
const hardResult = await client.deleteProfile(hardProfile.id, { force: true });
|
|
221
|
+
assert.deepEqual(hardResult, {
|
|
222
|
+
profile_id: hardProfile.id,
|
|
223
|
+
name: hardProfile.name,
|
|
224
|
+
mode: 'deleted',
|
|
225
|
+
nodes: 1,
|
|
226
|
+
crons: 2,
|
|
227
|
+
tickets: 2,
|
|
228
|
+
reviews: 1,
|
|
229
|
+
default_pins: 1,
|
|
230
|
+
});
|
|
231
|
+
closeDb();
|
|
232
|
+
assert.equal(h.node(hardNode), null, 'the matching node is hard-deleted');
|
|
233
|
+
assert.equal(h.node(crossChild)?.parent, hardNode, 'the cross-profile child and topology are preserved');
|
|
234
|
+
assert.equal(h.node(crossChild)?.profile_id, crossProfile.id);
|
|
235
|
+
assert.equal(getCron('hard-profile-cron'), null);
|
|
236
|
+
assert.equal(getCron('hard-anchor-cron'), null);
|
|
237
|
+
assert.equal(getCron('cross-preserved-cron')?.profile, crossProfile.id);
|
|
238
|
+
assert.equal(existsSync(standaloneTicket), false, 'standalone originating ticket directory is removed');
|
|
239
|
+
assert.equal(existsSync(join(nodeDir(crossChild), 'page.json')), false, 'ticket files are removed from a preserved node directory');
|
|
240
|
+
assert.equal(existsSync(join(nodeDir(crossChild), 'feedback.json')), false, 'default review output is removed with the ticket');
|
|
241
|
+
assert.equal(existsSync(join(nodeDir(crossChild), '.ticket-lock')), false, 'ticket lock residue is removed');
|
|
242
|
+
assert.equal(readFileSync(join(nodeDir(crossChild), 'cross-profile-state.txt'), 'utf8'), 'preserve');
|
|
243
|
+
assert.equal(getReview(hardReviewId), null);
|
|
244
|
+
assert.equal(getComment(hardReview.commentId), null);
|
|
245
|
+
assert.deepEqual(listReviewEvents(hardReviewId, {}), { events: [], next_since_seq: 0 });
|
|
246
|
+
assert.equal(existsSync(reviewDir(hardReviewId)), false, 'review-owned filesystem state is removed');
|
|
247
|
+
assert.equal(readFileSync(hardReview.sourceFile, 'utf8'), 'source\n', 'the reviewed source document is never removed');
|
|
248
|
+
assert.equal(getDefaultProfileId(hardPin), null);
|
|
249
|
+
await assert.rejects(client.getProfile(hardProfile.id), (error) => error instanceof ApiError && error.status === 404);
|
|
250
|
+
assert.throws(() => spawnNode({
|
|
251
|
+
kind: 'developer',
|
|
252
|
+
cwd: process.cwd(),
|
|
253
|
+
parent: null,
|
|
254
|
+
profile_id: hardProfile.id,
|
|
255
|
+
nodeId: 'hard-stale-birth-deadbeef',
|
|
256
|
+
}), /profile not found/, 'the final synchronous birth gate rejects a profile resolved before a completed hard deletion');
|
|
257
|
+
assert.equal(h.node('hard-stale-birth-deadbeef'), null);
|
|
258
|
+
// Detach preserves nodes, topology, ticket/review state, and conversations;
|
|
259
|
+
// matching brokers restart with no profile identity and matching crons become
|
|
260
|
+
// global rather than disappearing.
|
|
261
|
+
const detachProfile = await client.ensureProfile('delete-detach');
|
|
262
|
+
const detachNode = (await client.createNode({
|
|
263
|
+
kind: 'developer',
|
|
264
|
+
profile: detachProfile.id,
|
|
265
|
+
root: true,
|
|
266
|
+
cwd: process.cwd(),
|
|
267
|
+
prompt: 'detach lifecycle broker',
|
|
268
|
+
node_id: 'detach-live-deadbeef',
|
|
269
|
+
})).node_id;
|
|
270
|
+
await h.awaitBoot(detachNode);
|
|
271
|
+
const detachBoots = h.bootCount(detachNode);
|
|
272
|
+
const detachSessionFile = h.node(detachNode)?.pi_session_file;
|
|
273
|
+
assert.ok(detachSessionFile != null, 'the live detach fixture has a resumable conversation');
|
|
274
|
+
armTestCron({ id: 'detach-profile-cron', profile: detachProfile.id, anchor: detachNode });
|
|
275
|
+
const detachPin = join(h.agentHome, 'detach-pin');
|
|
276
|
+
mkdirSync(detachPin, { recursive: true });
|
|
277
|
+
setDefaultProfileId(detachPin, detachProfile.id);
|
|
278
|
+
const detachTicket = join(nodesRoot(), 'detach-ticket-deadbeef');
|
|
279
|
+
seedTicket(detachTicket, detachNode);
|
|
280
|
+
const detachReviewId = 'detach-review-deadbeef';
|
|
281
|
+
const detachReview = seedReview(detachReviewId, detachNode);
|
|
282
|
+
const detachPid = h.node(detachNode)?.pi_pid;
|
|
283
|
+
assert.ok(detachPid != null, 'the detach race fixture has a live broker pid');
|
|
284
|
+
process.kill(detachPid, 'SIGSTOP');
|
|
285
|
+
const detachDeletion = client.deleteProfile(detachProfile.id, { force: true, detach: true });
|
|
286
|
+
try {
|
|
287
|
+
await h.waitFor(() => h.node(detachNode)?.intent === 'idle-release', {
|
|
288
|
+
timeoutMs: 10_000,
|
|
289
|
+
intervalMs: 25,
|
|
290
|
+
label: 'detach reaches its broker-exit wait',
|
|
291
|
+
});
|
|
292
|
+
await assert.rejects(client.createNode({
|
|
293
|
+
kind: 'developer',
|
|
294
|
+
profile: detachProfile.id,
|
|
295
|
+
root: true,
|
|
296
|
+
cwd: process.cwd(),
|
|
297
|
+
prompt: 'must not escape the accepted detach plan',
|
|
298
|
+
node_id: 'detach-race-deadbeef',
|
|
299
|
+
}), (error) => {
|
|
300
|
+
if (!(error instanceof ApiError) || error.status !== 400 || error.code !== 'usage')
|
|
301
|
+
return false;
|
|
302
|
+
const details = error.details;
|
|
303
|
+
return details?.error === 'profile_deletion_in_progress';
|
|
304
|
+
}, 'a node whose async preflight crosses detach is rejected at the synchronous birth gate');
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
if (isPidAlive(detachPid))
|
|
308
|
+
process.kill(detachPid, 'SIGCONT');
|
|
309
|
+
}
|
|
310
|
+
const detachResult = await detachDeletion;
|
|
311
|
+
assert.deepEqual(detachResult, {
|
|
312
|
+
profile_id: detachProfile.id,
|
|
313
|
+
name: detachProfile.name,
|
|
314
|
+
mode: 'detached',
|
|
315
|
+
nodes: 1,
|
|
316
|
+
crons: 1,
|
|
317
|
+
tickets: 0,
|
|
318
|
+
reviews: 0,
|
|
319
|
+
default_pins: 1,
|
|
320
|
+
});
|
|
321
|
+
const restarted = await h.awaitBoot(detachNode, { minCount: detachBoots + 1 });
|
|
322
|
+
closeDb();
|
|
323
|
+
assert.equal(h.node(detachNode)?.profile_id, null);
|
|
324
|
+
assert.equal(h.node('detach-race-deadbeef'), null, 'no profile-scoped node escapes the fixed detach plan');
|
|
325
|
+
assert.equal(h.node(detachNode)?.pi_session_file, detachSessionFile, 'detach preserves the conversation file');
|
|
326
|
+
assert.equal(restarted.sessionFile, detachSessionFile, 'the restarted broker resumes that conversation file');
|
|
327
|
+
assert.equal(restarted.resuming, true, 'the previously live broker is restarted as a resume');
|
|
328
|
+
assert.equal(restarted.env['CRTR_PROFILE_ID'], '', 'the restarted broker no longer names the deleted profile');
|
|
329
|
+
assert.equal(getCron('detach-profile-cron')?.scope, 'global');
|
|
330
|
+
assert.equal(getCron('detach-profile-cron')?.profile, null);
|
|
331
|
+
assert.equal(existsSync(join(detachTicket, 'page.json')), true);
|
|
332
|
+
assert.equal(getReview(detachReviewId)?.origin_node_id, detachNode);
|
|
333
|
+
assert.ok(getComment(detachReview.commentId) !== null);
|
|
334
|
+
assert.equal(existsSync(reviewDir(detachReviewId)), true);
|
|
335
|
+
assert.equal(getDefaultProfileId(detachPin), null);
|
|
336
|
+
await assert.rejects(client.getProfile(detachProfile.id), (error) => error instanceof ApiError && error.status === 404);
|
|
337
|
+
// A node deleting its own profile receives HTTP 202 while its row and broker
|
|
338
|
+
// are still alive. The daemon performs the accepted hard deletion only after
|
|
339
|
+
// the response grace has elapsed.
|
|
340
|
+
const selfProfile = await client.ensureProfile('delete-self');
|
|
341
|
+
const selfNode = (await client.createNode({
|
|
342
|
+
kind: 'developer',
|
|
343
|
+
profile: selfProfile.id,
|
|
344
|
+
root: true,
|
|
345
|
+
cwd: process.cwd(),
|
|
346
|
+
prompt: 'self-delete lifecycle broker',
|
|
347
|
+
node_id: 'self-delete-deadbeef',
|
|
348
|
+
})).node_id;
|
|
349
|
+
await h.awaitBoot(selfNode);
|
|
350
|
+
const selfPid = h.node(selfNode)?.pi_pid;
|
|
351
|
+
assert.ok(selfPid != null && isPidAlive(selfPid), 'self-delete fixture starts with a live broker');
|
|
352
|
+
const accepted = await rawDelete(selfProfile.id, {
|
|
353
|
+
force: true,
|
|
354
|
+
caller_node_id: selfNode,
|
|
355
|
+
});
|
|
356
|
+
assert.equal(accepted.status, 202);
|
|
357
|
+
assert.deepEqual(accepted.body, {
|
|
358
|
+
profile_id: selfProfile.id,
|
|
359
|
+
name: selfProfile.name,
|
|
360
|
+
mode: 'deleted',
|
|
361
|
+
nodes: 1,
|
|
362
|
+
crons: 0,
|
|
363
|
+
tickets: 0,
|
|
364
|
+
reviews: 0,
|
|
365
|
+
default_pins: 0,
|
|
366
|
+
accepted: true,
|
|
367
|
+
grace_ms: 1000,
|
|
368
|
+
});
|
|
369
|
+
assert.equal(h.node(selfNode)?.profile_id, selfProfile.id, 'the 202 response settles before node mutation');
|
|
370
|
+
assert.equal(isPidAlive(selfPid), true, 'the 202 response settles before broker teardown');
|
|
371
|
+
updateNode(selfNode, { managed_worktree: managedWorktree() });
|
|
372
|
+
const repeatedAccepted = await rawDelete(selfProfile.id, {
|
|
373
|
+
force: true,
|
|
374
|
+
caller_node_id: selfNode,
|
|
375
|
+
});
|
|
376
|
+
updateNode(selfNode, { managed_worktree: null });
|
|
377
|
+
assert.equal(repeatedAccepted.status, 202, 'a retry returns the accepted result before observing new blockers');
|
|
378
|
+
assert.deepEqual(repeatedAccepted.body, accepted.body, 'a pending self-deletion is idempotent by profile identity');
|
|
379
|
+
await h.waitFor(() => h.node(selfNode) === null, {
|
|
380
|
+
timeoutMs: 15_000,
|
|
381
|
+
intervalMs: 25,
|
|
382
|
+
label: 'accepted self-deletion completes after acknowledgement grace',
|
|
383
|
+
});
|
|
384
|
+
assert.equal(loadProfileManifest(crossProfile.id).profileId, crossProfile.id, 'unrelated profiles remain resolvable');
|
|
385
|
+
await assert.rejects(client.getProfile(selfProfile.id), (error) => error instanceof ApiError && error.status === 404);
|
|
386
|
+
});
|
|
@@ -66,7 +66,7 @@ async function handleStop(ctx) {
|
|
|
66
66
|
// that same fail-loud behavior here after the job control-plane mutation.
|
|
67
67
|
assertNotFinalized(nodeId);
|
|
68
68
|
const paths = bashJobPaths(dir, jobId);
|
|
69
|
-
const body = `Background bash job ${jobId} was stopped by the
|
|
69
|
+
const body = `Background bash job ${jobId} was stopped by the user before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`;
|
|
70
70
|
appendInbox(nodeId, {
|
|
71
71
|
from: null,
|
|
72
72
|
tier: 'urgent',
|
|
@@ -11,6 +11,7 @@ import { readCursor, readInboxSince, writeCursor } from '../../../core/feed/inbo
|
|
|
11
11
|
import { clearYieldMessage } from '../../../core/runtime/kickoff.js';
|
|
12
12
|
import { transition } from '../../../core/runtime/lifecycle.js';
|
|
13
13
|
import { focusOf, tearDownNode } from '../../../core/runtime/placement.js';
|
|
14
|
+
import { hasAttachedBrokerViewers } from '../../../core/runtime/broker/client-registry.js';
|
|
14
15
|
import { handleNewSession } from '../../../core/runtime/reset.js';
|
|
15
16
|
import { evaluateStop } from '../../../core/runtime/stop-guard.js';
|
|
16
17
|
import { clearFault } from '../../../core/runtime/fault.js';
|
|
@@ -46,7 +47,7 @@ function parseSettleBody(body) {
|
|
|
46
47
|
const value = objectBody(body, 'broker settle');
|
|
47
48
|
if (typeof value['stopReason'] !== 'string')
|
|
48
49
|
throw usage('stopReason must be a string');
|
|
49
|
-
for (const field of ['backgroundJobsRunning', 'pushedFinal', '
|
|
50
|
+
for (const field of ['backgroundJobsRunning', 'pushedFinal', 'requestedHumanReply']) {
|
|
50
51
|
if (typeof value[field] !== 'boolean')
|
|
51
52
|
throw usage(`${field} must be a boolean`);
|
|
52
53
|
}
|
|
@@ -97,6 +98,7 @@ function extensionNode(node) {
|
|
|
97
98
|
profile_id: node.profile_id ?? null,
|
|
98
99
|
...(node.managed_worktree === undefined ? {} : { managed_worktree: node.managed_worktree === null ? null : {
|
|
99
100
|
state: node.managed_worktree.state,
|
|
101
|
+
...(node.managed_worktree.cleanup === undefined ? {} : { cleanup: node.managed_worktree.cleanup }),
|
|
100
102
|
path: node.managed_worktree.path,
|
|
101
103
|
branch: node.managed_worktree.branch,
|
|
102
104
|
base_ref: node.managed_worktree.base_ref,
|
|
@@ -234,7 +236,7 @@ function settleBroker(nodeId, request) {
|
|
|
234
236
|
// broker alive while never fabricating a replacement canvas row.
|
|
235
237
|
if (request.pushedFinal)
|
|
236
238
|
return { action: 'shutdown' };
|
|
237
|
-
if (request.
|
|
239
|
+
if (request.requestedHumanReply)
|
|
238
240
|
return { action: 'stay_dormant' };
|
|
239
241
|
throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
|
|
240
242
|
}
|
|
@@ -254,7 +256,7 @@ function settleBroker(nodeId, request) {
|
|
|
254
256
|
clearFault(nodeId, { link: 'daemon→node' });
|
|
255
257
|
return node.intent === 'refresh' ? { action: 'shutdown' } : { action: 'stay_dormant' };
|
|
256
258
|
}
|
|
257
|
-
const signals = { pushedFinal: request.pushedFinal,
|
|
259
|
+
const signals = { pushedFinal: request.pushedFinal, requestedHumanReply: request.requestedHumanReply };
|
|
258
260
|
// Two current-state outcomes: terminal broker consequences, not Pi decisions.
|
|
259
261
|
if (signals.pushedFinal || node.status === 'done') {
|
|
260
262
|
clearFault(nodeId);
|
|
@@ -263,7 +265,7 @@ function settleBroker(nodeId, request) {
|
|
|
263
265
|
setPresence(nodeId, { pane: null, tmux_session: null, window: null });
|
|
264
266
|
return { action: 'shutdown' };
|
|
265
267
|
}
|
|
266
|
-
if (signals.
|
|
268
|
+
if (signals.requestedHumanReply) {
|
|
267
269
|
clearFault(nodeId);
|
|
268
270
|
return { action: 'stay_dormant' };
|
|
269
271
|
}
|
|
@@ -281,7 +283,9 @@ function settleBroker(nodeId, request) {
|
|
|
281
283
|
if (decision.reason !== 'awaiting' && decision.reason !== 'scheduled') {
|
|
282
284
|
return { action: 'stay_dormant' };
|
|
283
285
|
}
|
|
284
|
-
if (focusOf(nodeId) !== null
|
|
286
|
+
if (focusOf(nodeId) !== null
|
|
287
|
+
|| hasAttachedBrokerViewers(nodeId)
|
|
288
|
+
|| hasUnseenNonDeferredInbox(nodeId))
|
|
285
289
|
return { action: 'stay_dormant' };
|
|
286
290
|
transition(nodeId, 'release');
|
|
287
291
|
return { action: 'shutdown' };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Page feedback-comment routes — notes the human leaves on highlighted page
|
|
2
|
+
// content, addressed by the same opaque inbox ticket ids as the page routes.
|
|
3
|
+
// The first comment on a ticket realizes its companion conversation — a fork
|
|
4
|
+
// of the sending node's current state, bound to the ticket for good — and
|
|
5
|
+
// every comment is then delivered to that companion as the person's own turn,
|
|
6
|
+
// post-commit and best-effort. A sent comment is immutable: the only mutations
|
|
7
|
+
// are create and the companion's resolve. Feedback never enters the page
|
|
8
|
+
// response the sender reads at settle — it was already delivered; only
|
|
9
|
+
// unresolved items ride the completion as an addendum.
|
|
10
|
+
import { basename } from 'node:path';
|
|
11
|
+
import { ApiError } from '../../../api/index.js';
|
|
12
|
+
import { createPageFeedbackComment, readPageFeedback, resolvePageFeedbackComment, PageFeedbackCommentAlreadyResolvedError, PageTicketTerminalError, UnknownPageFeedbackCommentError, } from '../../../core/human/feedback.js';
|
|
13
|
+
import { discardFeedbackCompanion, pageDocumentSha256, realizeFeedbackCompanion } from '../../../core/human/feedback-companion.js';
|
|
14
|
+
import { parsePage } from '../../../core/human/page.js';
|
|
15
|
+
import { pageTicketState } from '../../../core/human/tickets.js';
|
|
16
|
+
import { deliverNodeMessage } from '../../messaging/node-message.js';
|
|
17
|
+
import { requireTicketId, resolveTicket } from './inbox.js';
|
|
18
|
+
import { invalidRequest, objectBody, optionalString, requiredString } from './validate.js';
|
|
19
|
+
import { toPageFeedbackDTO, toPageFeedbackCommentDTO } from '../map.js';
|
|
20
|
+
const COMMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Shared guards
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/** Resolve a mutable page ticket: it must exist, be a page, and not have a
|
|
25
|
+
* terminal outcome. Passive pages never settle, so they stay commentable.
|
|
26
|
+
* A terminal ticket refuses every feedback action — settlement won. */
|
|
27
|
+
function requireMutablePageTicket(ctx) {
|
|
28
|
+
const ticketId = requireTicketId(ctx.params);
|
|
29
|
+
const resolved = resolveTicket(ticketId);
|
|
30
|
+
if (resolved === null)
|
|
31
|
+
throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
|
|
32
|
+
if (resolved.kind === 'review') {
|
|
33
|
+
throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no feedback comments');
|
|
34
|
+
}
|
|
35
|
+
const state = pageTicketState(resolved.dir);
|
|
36
|
+
if (state === 'resolved' || state === 'canceled') {
|
|
37
|
+
throw new ApiError(409, 'ticket_terminal', 'ticket already has a terminal outcome');
|
|
38
|
+
}
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
function requireCommentId(params) {
|
|
42
|
+
const commentId = params['comment_id'];
|
|
43
|
+
if (commentId === undefined || !COMMENT_ID.test(commentId)) {
|
|
44
|
+
throw new ApiError(400, 'invalid_request', 'invalid feedback comment id');
|
|
45
|
+
}
|
|
46
|
+
return commentId;
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Delivery — the person's turn in the companion conversation
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/** The comment as the person's own words: the quoted page content and the
|
|
52
|
+
* note, nothing else. The companion's briefing rides its bearings and
|
|
53
|
+
* persona, never the human turn. */
|
|
54
|
+
function renderCommentTurn(comment) {
|
|
55
|
+
const quoted = comment.quote.split('\n').map((line) => `> ${line}`);
|
|
56
|
+
return [...quoted, '', comment.note].join('\n');
|
|
57
|
+
}
|
|
58
|
+
async function deliverCommentTurn(args) {
|
|
59
|
+
const target = args.mutation.feedback.companionNodeId;
|
|
60
|
+
if (target === undefined) {
|
|
61
|
+
// Comments exist only after a create recorded the companion, so a missing
|
|
62
|
+
// companion here is corrupted feedback state, not a deliverable situation.
|
|
63
|
+
throw new ApiError(500, 'internal', 'feedback has comments but no companion conversation');
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const delivery = await deliverNodeMessage({
|
|
67
|
+
node_id: target,
|
|
68
|
+
body: renderCommentTurn(args.mutation.comment),
|
|
69
|
+
label: args.title,
|
|
70
|
+
from: 'human',
|
|
71
|
+
mode: 'interactive',
|
|
72
|
+
data: { page_id: basename(args.dir), comment_id: args.mutation.comment.id, verb: 'create' },
|
|
73
|
+
});
|
|
74
|
+
return { target_node_id: target, status: 'delivered', via: delivery.via, woke: delivery.revived };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// State was committed before delivery; a delivery failure remains a
|
|
78
|
+
// successful mutation.
|
|
79
|
+
return { target_node_id: target, status: 'failed', woke: false, error: error instanceof Error ? error.message : String(error) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// POST /v1/human/inbox/:ticket_id/feedback-comments — create
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
function parseCreate(ctx) {
|
|
86
|
+
const violations = [];
|
|
87
|
+
const body = objectBody(ctx.body, ['quote', 'context', 'note'], violations);
|
|
88
|
+
const quote = requiredString(body, 'quote', violations);
|
|
89
|
+
const context = optionalString(body, 'context', violations);
|
|
90
|
+
const note = requiredString(body, 'note', violations);
|
|
91
|
+
if (violations.length > 0 || quote === undefined || note === undefined) {
|
|
92
|
+
invalidRequest('page feedback comment request', violations);
|
|
93
|
+
}
|
|
94
|
+
return { quote, ...(context === undefined ? {} : { context }), note };
|
|
95
|
+
}
|
|
96
|
+
async function handleCreate(ctx) {
|
|
97
|
+
const request = parseCreate(ctx);
|
|
98
|
+
const resolved = requireMutablePageTicket(ctx);
|
|
99
|
+
const manifest = parsePage(resolved.dir);
|
|
100
|
+
if (manifest.dialect !== 'jsx') {
|
|
101
|
+
throw new ApiError(409, 'page_dialect_unsupported', 'feedback comments exist only on TSX pages');
|
|
102
|
+
}
|
|
103
|
+
// Realize the companion before taking the ticket lock — forking is async and
|
|
104
|
+
// must not hold it. The store keeps an already-recorded companion, so a
|
|
105
|
+
// concurrent first comment wastes at most one idle fork (accepted
|
|
106
|
+
// single-human race). Any realization failure refuses the comment: no
|
|
107
|
+
// feedback is accepted without a visible conversation able to receive it.
|
|
108
|
+
let companionNodeId = readPageFeedback(resolved.dir).companionNodeId;
|
|
109
|
+
let firstCommentedSha256;
|
|
110
|
+
let realizedNow = false;
|
|
111
|
+
if (companionNodeId === undefined) {
|
|
112
|
+
const sender = manifest.source?.nodeId;
|
|
113
|
+
if (sender === undefined) {
|
|
114
|
+
throw new ApiError(409, 'ticket_has_no_sender', 'this page carries no sending node to deliver feedback to');
|
|
115
|
+
}
|
|
116
|
+
firstCommentedSha256 = pageDocumentSha256(resolved.dir);
|
|
117
|
+
try {
|
|
118
|
+
companionNodeId = await realizeFeedbackCompanion({
|
|
119
|
+
ticketId: resolved.ticketId,
|
|
120
|
+
dir: resolved.dir,
|
|
121
|
+
senderNodeId: sender,
|
|
122
|
+
title: manifest.title,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw new ApiError(409, 'fork_failed', `the sending node could not be forked: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
}
|
|
128
|
+
realizedNow = true;
|
|
129
|
+
}
|
|
130
|
+
// The fork awaited its bind outside the ticket lock, so settlement may have
|
|
131
|
+
// won meanwhile: the create mutation re-checks the ticket under the lock and
|
|
132
|
+
// refuses after a terminal outcome. A companion realized by THIS request is
|
|
133
|
+
// discarded whenever its commit did not land — refusal, write failure, or a
|
|
134
|
+
// lost first-comment race (the recorded companion wins; ours is a stray).
|
|
135
|
+
let mutation;
|
|
136
|
+
try {
|
|
137
|
+
mutation = createPageFeedbackComment(resolved.dir, { ...request, companionNodeId, firstCommentedSha256 });
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (realizedNow)
|
|
141
|
+
discardFeedbackCompanion(companionNodeId);
|
|
142
|
+
if (error instanceof PageTicketTerminalError) {
|
|
143
|
+
throw new ApiError(409, 'ticket_terminal', 'ticket already has a terminal outcome');
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
if (realizedNow && mutation.feedback.companionNodeId !== companionNodeId) {
|
|
148
|
+
discardFeedbackCompanion(companionNodeId);
|
|
149
|
+
}
|
|
150
|
+
const body = {
|
|
151
|
+
comment: toPageFeedbackCommentDTO(mutation.comment),
|
|
152
|
+
feedback: toPageFeedbackDTO(mutation.feedback),
|
|
153
|
+
delivery: await deliverCommentTurn({ dir: resolved.dir, title: manifest.title, mutation }),
|
|
154
|
+
};
|
|
155
|
+
return { status: 200, body };
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// POST .../feedback-comments/:comment_id/resolve — the companion's report
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
function parseResolve(ctx) {
|
|
161
|
+
const violations = [];
|
|
162
|
+
const body = objectBody(ctx.body, ['node_id'], violations);
|
|
163
|
+
const nodeId = requiredString(body, 'node_id', violations);
|
|
164
|
+
if (violations.length > 0 || nodeId === undefined) {
|
|
165
|
+
invalidRequest('feedback resolve request', violations);
|
|
166
|
+
}
|
|
167
|
+
return nodeId;
|
|
168
|
+
}
|
|
169
|
+
async function handleResolve(ctx) {
|
|
170
|
+
const resolved = requireMutablePageTicket(ctx);
|
|
171
|
+
const commentId = requireCommentId(ctx.params);
|
|
172
|
+
const callerNodeId = parseResolve(ctx);
|
|
173
|
+
// Resolution is the bound companion's report that an item has been dealt
|
|
174
|
+
// with; no other node may retire the person's open item out from under the
|
|
175
|
+
// settle-time addendum.
|
|
176
|
+
const companionNodeId = readPageFeedback(resolved.dir).companionNodeId;
|
|
177
|
+
if (companionNodeId === undefined || callerNodeId !== companionNodeId) {
|
|
178
|
+
throw new ApiError(403, 'not_companion', "only the ticket's bound feedback companion resolves its comments");
|
|
179
|
+
}
|
|
180
|
+
let mutation;
|
|
181
|
+
try {
|
|
182
|
+
mutation = resolvePageFeedbackComment(resolved.dir, commentId);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (error instanceof UnknownPageFeedbackCommentError) {
|
|
186
|
+
throw new ApiError(404, 'unknown_comment', error.message);
|
|
187
|
+
}
|
|
188
|
+
if (error instanceof PageFeedbackCommentAlreadyResolvedError) {
|
|
189
|
+
throw new ApiError(409, 'comment_already_resolved', error.message);
|
|
190
|
+
}
|
|
191
|
+
if (error instanceof PageTicketTerminalError) {
|
|
192
|
+
throw new ApiError(409, 'ticket_terminal', 'ticket already has a terminal outcome');
|
|
193
|
+
}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
// Resolution appends no chat turn: the companion's own conversational report
|
|
197
|
+
// explains the result.
|
|
198
|
+
const body = {
|
|
199
|
+
comment: toPageFeedbackCommentDTO(mutation.comment),
|
|
200
|
+
feedback: toPageFeedbackDTO(mutation.feedback),
|
|
201
|
+
};
|
|
202
|
+
return { status: 200, body };
|
|
203
|
+
}
|
|
204
|
+
export const feedbackCommentRoutes = [
|
|
205
|
+
{ method: 'POST', pattern: '/v1/human/inbox/:ticket_id/feedback-comments', handler: handleCreate },
|
|
206
|
+
{ method: 'POST', pattern: '/v1/human/inbox/:ticket_id/feedback-comments/:comment_id/resolve', handler: handleResolve },
|
|
207
|
+
];
|