@north-light/crouter 0.3.281 → 0.3.283
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 +19 -3
- package/dist/api/client.js +34 -0
- package/dist/api/dto/broker-ops.d.ts +11 -0
- package/dist/api/dto/broker.d.ts +3 -8
- package/dist/api/dto/common.d.ts +1 -1
- package/dist/api/dto/messages.d.ts +2 -2
- package/dist/api/dto/node-outcomes.d.ts +80 -0
- package/dist/api/dto/node-outcomes.js +2 -0
- package/dist/api/dto/nodes.d.ts +9 -0
- package/dist/api/dto/profiles.d.ts +5 -0
- package/dist/api/dto/reports.d.ts +45 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/plugin-manifest-schema.d.ts +208 -0
- package/dist/api/plugin-manifest-schema.js +23 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
- package/dist/cli.js +8 -2
- package/dist/clients/attach/overlays/graph.d.ts +5 -0
- package/dist/clients/attach/overlays/graph.js +65 -47
- package/dist/clients/attach/overlays/pickers.js +8 -9
- package/dist/clients/attach/render/card-presentation.js +1 -1
- package/dist/clients/attach/viewer.js +1286 -1260
- package/dist/commands/api-client.js +22 -23
- package/dist/commands/node/create.js +38 -3
- package/dist/commands/node/inspect.js +3 -7
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/node/outcome.d.ts +2 -0
- package/dist/commands/node/outcome.js +134 -0
- package/dist/commands/node.js +2 -1
- package/dist/commands/pkg/browse/catalog.js +1 -1
- package/dist/commands/pkg/plugin-manage.d.ts +46 -1
- package/dist/commands/pkg/plugin-manage.js +170 -20
- package/dist/commands/profile/pause.js +17 -14
- package/dist/commands/push.js +102 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
- package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
- package/dist/commands/sys/panels/models-panel.js +2 -2
- package/dist/commands/sys/settings-shell.d.ts +1 -1
- package/dist/commands/sys/settings-shell.js +6 -0
- package/dist/commands/sys/settings.js +3 -3
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
- package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
- package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
- package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
- package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
- package/dist/core/__tests__/node-outcome.test.js +184 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
- package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
- package/dist/core/bash-jobs.d.ts +6 -0
- package/dist/core/bash-jobs.js +22 -0
- package/dist/core/canvas/canvas.d.ts +41 -3
- package/dist/core/canvas/canvas.js +153 -39
- package/dist/core/canvas/crons.js +2 -1
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/migrations.js +47 -0
- package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
- package/dist/core/canvas/node-outcome-deliveries.js +163 -0
- package/dist/core/canvas/pid.d.ts +7 -0
- package/dist/core/canvas/pid.js +17 -0
- package/dist/core/canvas/render-source.js +2 -2
- package/dist/core/canvas/types.d.ts +42 -1
- package/dist/core/canvas/types.js +2 -1
- package/dist/core/command-manifests/registry.d.ts +3 -1
- package/dist/core/command-manifests/schema.d.ts +2 -75
- package/dist/core/command-manifests/schema.js +5 -0
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/revalidate.d.ts +18 -0
- package/dist/core/command-plugins/revalidate.js +152 -0
- package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
- package/dist/core/command-plugins/transport/http-fetch.js +18 -8
- package/dist/core/command-plugins/transport/http-invoke.js +7 -0
- package/dist/core/command.d.ts +8 -0
- package/dist/core/command.js +19 -3
- package/dist/core/config.d.ts +4 -1
- package/dist/core/config.js +25 -1
- package/dist/core/exclusive-lock.d.ts +12 -0
- package/dist/core/exclusive-lock.js +29 -0
- package/dist/core/feed/feed.d.ts +8 -0
- package/dist/core/feed/feed.js +25 -4
- package/dist/core/feed/inbox.d.ts +5 -0
- package/dist/core/feed/inbox.js +13 -0
- package/dist/core/installed-plugins.js +31 -0
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +3 -0
- package/dist/core/manifest-recovery.d.ts +15 -0
- package/dist/core/manifest-recovery.js +72 -0
- package/dist/core/manifest-stale.d.ts +18 -0
- package/dist/core/manifest-stale.js +39 -0
- package/dist/core/plugin-swap-lock.d.ts +9 -0
- package/dist/core/plugin-swap-lock.js +31 -0
- package/dist/core/profiles/manifest.d.ts +3 -1
- package/dist/core/profiles/manifest.js +6 -1
- package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
- package/dist/core/runtime/broker/daemon-ops.js +6 -0
- package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
- package/dist/core/runtime/broker/engine-drive.js +74 -27
- package/dist/core/runtime/broker/frame-dispatch.js +1 -0
- package/dist/core/runtime/broker/read-ops.js +14 -0
- package/dist/core/runtime/broker/rebind.js +0 -7
- package/dist/core/runtime/broker-protocol.d.ts +1 -6
- package/dist/core/runtime/fleet.d.ts +7 -0
- package/dist/core/runtime/headless-pi.d.ts +13 -0
- package/dist/core/runtime/headless-pi.js +10 -2
- package/dist/core/runtime/lifecycle.d.ts +6 -1
- package/dist/core/runtime/lifecycle.js +50 -1
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +2 -1
- package/dist/core/runtime/outcome-document.d.ts +70 -0
- package/dist/core/runtime/outcome-document.js +97 -0
- package/dist/core/runtime/revive.js +16 -21
- package/dist/core/runtime/spawn.d.ts +5 -1
- package/dist/core/runtime/spawn.js +18 -7
- package/dist/core/runtime/stamp/channel.d.ts +3 -5
- package/dist/core/runtime/stamp/channel.js +3 -10
- package/dist/core/runtime/stop-guard.js +2 -2
- package/dist/core/runtime/structured-output.d.ts +24 -3
- package/dist/core/runtime/structured-output.js +42 -3
- package/dist/core/runtime/tmux-bindings.js +4 -0
- package/dist/core/runtime/warm-pool.d.ts +5 -2
- package/dist/core/runtime/warm-pool.js +5 -5
- package/dist/core/subscription-state.js +7 -5
- package/dist/core/substrate/render.js +10 -10
- package/dist/core/user-settings.d.ts +18 -1
- package/dist/core/user-settings.js +37 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
- package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
- package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
- package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
- package/dist/daemon/api/handlers/broker-ops.js +53 -24
- package/dist/daemon/api/handlers/canvas.js +2 -2
- package/dist/daemon/api/handlers/messages.js +12 -2
- package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
- package/dist/daemon/api/handlers/node-outcomes.js +136 -0
- package/dist/daemon/api/handlers/nodes.js +67 -1
- package/dist/daemon/api/handlers/profiles.js +53 -1
- package/dist/daemon/api/handlers/reports.js +197 -33
- package/dist/daemon/api/map.d.ts +7 -2
- package/dist/daemon/api/map.js +78 -4
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/crtrd.js +12 -0
- package/dist/daemon/fleet.d.ts +4 -8
- package/dist/daemon/fleet.js +48 -9
- package/dist/daemon/messaging/node-message.js +2 -1
- package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
- package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
- package/dist/daemon/park-activity.d.ts +9 -0
- package/dist/daemon/park-activity.js +30 -0
- package/dist/daemon/park-pending.d.ts +10 -5
- package/dist/daemon/park-pending.js +18 -8
- package/dist/daemon/reconcilers/bash-deadline.js +6 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
- package/dist/daemon/reconcilers/broker-supervision.js +29 -33
- package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
- package/dist/daemon/reconcilers/node-deadline.js +82 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
- package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
- package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
- package/dist/pi-extensions/canvas-structured-output.js +23 -297
- package/dist/shared/env.d.ts +3 -0
- package/dist/shared/env.js +6 -0
- package/dist/shared/generated-context.d.ts +3 -9
- package/dist/shared/generated-context.js +8 -16
- package/dist/types.d.ts +19 -0
- package/dist/types.js +1 -0
- package/package.json +9 -8
- package/runtime.lock.json +227 -383
|
@@ -37,6 +37,10 @@ import { activeFaultForDisplay } from '../../../core/canvas/render-source.js';
|
|
|
37
37
|
import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
|
|
38
38
|
import { boundFleet } from '../../../core/runtime/fleet.js';
|
|
39
39
|
import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
40
|
+
import { OUTCOME_PAYLOAD_MAX_BYTES } from '../../../core/canvas/types.js';
|
|
41
|
+
import { HUMAN_ACTION_NAME_PATTERN } from '../../../core/human/action-binding.js';
|
|
42
|
+
import { resolveHumanAction } from '../../../core/human-actions.js';
|
|
43
|
+
import { parseDurationMs } from '../../../core/wake.js';
|
|
40
44
|
import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
|
|
41
45
|
import { resolveLaunchTarget } from '../../../core/runtime/launch-target.js';
|
|
42
46
|
import { fullName } from '../../../core/canvas/labels.js';
|
|
@@ -115,6 +119,58 @@ function parseCreateBody(body) {
|
|
|
115
119
|
}
|
|
116
120
|
return b;
|
|
117
121
|
}
|
|
122
|
+
function parseDeadline(raw) {
|
|
123
|
+
if (raw === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
const durationMs = parseDurationMs(raw);
|
|
126
|
+
const deadlineAt = durationMs === null ? null : new Date(Date.now() + durationMs);
|
|
127
|
+
if (durationMs === null || !Number.isFinite(durationMs) || durationMs <= 0 || deadlineAt === null || !Number.isFinite(deadlineAt.getTime())) {
|
|
128
|
+
throw new ApiError(422, 'invalid_deadline', '`deadline` must be a positive representable duration');
|
|
129
|
+
}
|
|
130
|
+
return deadlineAt.toISOString();
|
|
131
|
+
}
|
|
132
|
+
function parseOutcomeDelivery(raw) {
|
|
133
|
+
if (raw === undefined)
|
|
134
|
+
return undefined;
|
|
135
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
136
|
+
throw new ApiError(400, 'invalid_request', '`outcome_delivery` must be an object');
|
|
137
|
+
}
|
|
138
|
+
if (typeof raw.action !== 'string' || !HUMAN_ACTION_NAME_PATTERN.test(raw.action)) {
|
|
139
|
+
throw new ApiError(400, 'invalid_request', '`outcome_delivery.action` must be a declared action name');
|
|
140
|
+
}
|
|
141
|
+
if (raw.payload !== undefined) {
|
|
142
|
+
let serialized;
|
|
143
|
+
try {
|
|
144
|
+
serialized = JSON.stringify(raw.payload);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new ApiError(400, 'invalid_request', '`outcome_delivery.payload` must be JSON-serializable');
|
|
148
|
+
}
|
|
149
|
+
if (serialized === undefined) {
|
|
150
|
+
throw new ApiError(400, 'invalid_request', '`outcome_delivery.payload` must be JSON-serializable');
|
|
151
|
+
}
|
|
152
|
+
if (Buffer.byteLength(serialized, 'utf8') > OUTCOME_PAYLOAD_MAX_BYTES) {
|
|
153
|
+
throw new ApiError(413, 'payload_too_large', `serialized payload exceeds ${OUTCOME_PAYLOAD_MAX_BYTES} bytes`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return raw;
|
|
157
|
+
}
|
|
158
|
+
function resolveOutcomeDelivery(request, cwd) {
|
|
159
|
+
const resolution = resolveHumanAction(request.action, cwd);
|
|
160
|
+
if (!('kind' in resolution)) {
|
|
161
|
+
return {
|
|
162
|
+
actionName: request.action,
|
|
163
|
+
argv: resolution.argv,
|
|
164
|
+
cwd: resolution.cwd,
|
|
165
|
+
configOrigin: resolution.configOrigin,
|
|
166
|
+
...(request.payload === undefined ? {} : { payload: request.payload }),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (resolution.kind === 'unknown_action') {
|
|
170
|
+
throw new ApiError(422, 'unknown_action', `no action named ${request.action} is declared from ${cwd}`);
|
|
171
|
+
}
|
|
172
|
+
throw new ApiError(422, 'invalid_action', `${request.action} is declared in ${resolution.configOrigin} but unusable: ${resolution.reason}`, { reason: resolution.reason, configOrigin: resolution.configOrigin });
|
|
173
|
+
}
|
|
118
174
|
/** Build the immediate spawn recipe. */
|
|
119
175
|
function buildRecipe(req, parent, cwd) {
|
|
120
176
|
const recipe = {
|
|
@@ -212,6 +268,8 @@ async function assertNodeModelChangeRegistered(meta, target, registry) {
|
|
|
212
268
|
}
|
|
213
269
|
export async function handleCreate(ctx, deps = {}) {
|
|
214
270
|
const req = parseCreateBody(ctx.body);
|
|
271
|
+
const deadline = parseDeadline(req.deadline);
|
|
272
|
+
const outcomeDelivery = parseOutcomeDelivery(req.outcome_delivery);
|
|
215
273
|
// `no_kickoff` needs no dedicated recipe field: an empty prompt IS the
|
|
216
274
|
// no-kickoff mechanism (spawnChild runs no launch turn on prompt===''), and
|
|
217
275
|
// buildRecipe already defaults `prompt` to ''. The CLI keeps every no_kickoff
|
|
@@ -237,13 +295,17 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
237
295
|
// to this daemon, never to the possibly-remote CLI caller.
|
|
238
296
|
const { profileId, kind, cwd, launch } = await resolveLaunchTarget(req, { validateKind: assertKindServer });
|
|
239
297
|
const resolvedReq = { ...req, kind };
|
|
298
|
+
const resolvedOutcomeDelivery = outcomeDelivery === undefined ? undefined : resolveOutcomeDelivery(outcomeDelivery, cwd);
|
|
240
299
|
await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
|
|
241
300
|
// --- Warm path: hand out an already-booted spare instead of paying a full
|
|
242
301
|
// engine boot. A miss is not a failure — it falls through to the cold
|
|
243
302
|
// spawn below and mints the spare that serves the NEXT create. ---
|
|
244
303
|
const warm = warmRequestFor(resolvedReq, cwd, profileId);
|
|
245
304
|
if (warm !== null) {
|
|
246
|
-
const claimed = await claimWarmNode(warm
|
|
305
|
+
const claimed = await claimWarmNode(warm, {
|
|
306
|
+
...(deadline === undefined ? {} : { deadlineAt: deadline }),
|
|
307
|
+
...(resolvedOutcomeDelivery === undefined ? {} : { outcomeDelivery: resolvedOutcomeDelivery }),
|
|
308
|
+
});
|
|
247
309
|
refillWarmPool(warm);
|
|
248
310
|
if (claimed !== null) {
|
|
249
311
|
if (schema !== null)
|
|
@@ -252,6 +314,10 @@ export async function handleCreate(ctx, deps = {}) {
|
|
|
252
314
|
}
|
|
253
315
|
}
|
|
254
316
|
const recipe = buildRecipe(resolvedReq, parent, cwd);
|
|
317
|
+
if (deadline !== undefined)
|
|
318
|
+
recipe.deadlineAt = deadline;
|
|
319
|
+
if (resolvedOutcomeDelivery !== undefined)
|
|
320
|
+
recipe.outcomeDelivery = resolvedOutcomeDelivery;
|
|
255
321
|
recipe.readiness = req.root === true ? 'await' : 'launch';
|
|
256
322
|
const result = await spawnChild(recipe);
|
|
257
323
|
if (schema !== null)
|
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
// GET /v1/profiles/:name resolve one profile by name or id
|
|
8
8
|
// DELETE /v1/profiles/:name required-force cascade or detach
|
|
9
9
|
import { assertInstalledKind } from '../../../core/config.js';
|
|
10
|
+
import { listNodesByProfile, shiftProfileNodeDeadlines } from '../../../core/canvas/canvas.js';
|
|
11
|
+
import { shiftAutoBackgroundBashDeadlines } from '../../../core/bash-jobs.js';
|
|
12
|
+
import { contextDir } from '../../../core/canvas/paths.js';
|
|
13
|
+
import { boundFleet } from '../../../core/runtime/fleet.js';
|
|
14
|
+
import { recordedPidLiveness } from '../../../core/canvas/pid.js';
|
|
10
15
|
import { usage } from '../../../core/errors.js';
|
|
11
|
-
import { assertProfileProjects, createProfile, ensureRootProfile, listProfiles, loadProfileManifest, updateProfileMetadata, } from '../../../core/profiles/manifest.js';
|
|
16
|
+
import { assertProfileProjects, createProfile, ensureRootProfile, listProfiles, loadProfileManifest, pauseProfile, resumeProfile, updateProfileMetadata, } from '../../../core/profiles/manifest.js';
|
|
12
17
|
import { requestProfileDeletion } from '../../profile-delete.js';
|
|
13
18
|
import { toProfileDTO } from '../map.js';
|
|
14
19
|
const ROOT_PROFILE_NAME = 'root';
|
|
@@ -62,6 +67,51 @@ function handleSetMetadata(ctx) {
|
|
|
62
67
|
const updated = updateProfileMetadata(profile.profileId, set, unset);
|
|
63
68
|
return { status: 200, body: toProfileDTO(updated) };
|
|
64
69
|
}
|
|
70
|
+
/** `POST /v1/profiles/:name/pause` — make the profile inert, then directly
|
|
71
|
+
* SIGTERM its live brokers. This deliberately avoids host teardown: the
|
|
72
|
+
* broker's SIGTERM path hands foreground bash jobs to their detached
|
|
73
|
+
* supervisors, while shutdown would abort and remove them. */
|
|
74
|
+
function handlePause(ctx) {
|
|
75
|
+
const profile = loadProfileManifest(ctx.params['name']);
|
|
76
|
+
const paused = pauseProfile(profile.profileId);
|
|
77
|
+
const nodes = listNodesByProfile(profile.profileId).filter((node) => node.status === 'active' || node.status === 'idle');
|
|
78
|
+
if (nodes.length === 0)
|
|
79
|
+
return { status: 200, body: { profile_id: profile.profileId, paused_at: paused.manifest.paused_at } };
|
|
80
|
+
const fleet = boundFleet();
|
|
81
|
+
for (const node of nodes) {
|
|
82
|
+
const live = fleet.get(node.node_id);
|
|
83
|
+
if (live !== undefined) {
|
|
84
|
+
fleet.interruptForProfilePause(node.node_id, live.pid);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (node.pi_pid != null && recordedPidLiveness(node.pi_pid, node.pi_pid_identity) === 'alive') {
|
|
88
|
+
fleet.interruptForProfilePause(node.node_id, node.pi_pid);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { status: 200, body: { profile_id: profile.profileId, paused_at: paused.manifest.paused_at } };
|
|
92
|
+
}
|
|
93
|
+
/** `POST /v1/profiles/:name/resume` — restore admission and credit active
|
|
94
|
+
* profile deadlines for the interval during which the profile was inert. The
|
|
95
|
+
* lifecycle tick owns broker recovery after the pause interruption settles. */
|
|
96
|
+
function handleResume(ctx) {
|
|
97
|
+
const profile = loadProfileManifest(ctx.params['name']);
|
|
98
|
+
const resumed = resumeProfile(profile.profileId);
|
|
99
|
+
if (resumed.pausedAt !== null) {
|
|
100
|
+
const pausedAt = Date.parse(resumed.pausedAt);
|
|
101
|
+
if (!Number.isNaN(pausedAt)) {
|
|
102
|
+
const pausedMs = Math.max(0, Date.now() - pausedAt);
|
|
103
|
+
shiftProfileNodeDeadlines(profile.profileId, pausedMs);
|
|
104
|
+
// Background bash claim windows are the same kind of budget as a node
|
|
105
|
+
// deadline: time the node was allowed to act, not wall-clock time.
|
|
106
|
+
for (const node of listNodesByProfile(profile.profileId)) {
|
|
107
|
+
if (node.status === 'dead')
|
|
108
|
+
continue;
|
|
109
|
+
shiftAutoBackgroundBashDeadlines(contextDir(node.node_id), pausedMs);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { status: 200, body: { profile_id: profile.profileId, paused_at: resumed.manifest.paused_at } };
|
|
114
|
+
}
|
|
65
115
|
/** `GET /v1/profiles` — every profile with a readable manifest. */
|
|
66
116
|
function handleList() {
|
|
67
117
|
return { status: 200, body: listProfiles().map(toProfileDTO) };
|
|
@@ -81,6 +131,8 @@ async function handleDelete(ctx) {
|
|
|
81
131
|
}
|
|
82
132
|
export const profileRoutes = [
|
|
83
133
|
{ method: 'PUT', pattern: '/v1/profiles/:name', handler: handleEnsure },
|
|
134
|
+
{ method: 'POST', pattern: '/v1/profiles/:name/pause', handler: handlePause },
|
|
135
|
+
{ method: 'POST', pattern: '/v1/profiles/:name/resume', handler: handleResume },
|
|
84
136
|
{ method: 'PATCH', pattern: '/v1/profiles/:name/metadata', handler: handleSetMetadata },
|
|
85
137
|
{ method: 'DELETE', pattern: '/v1/profiles/:name', handler: handleDelete },
|
|
86
138
|
{ method: 'GET', pattern: '/v1/profiles', handler: handleList },
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// push route — `tier` is the report kind and update-only `delivery_tier` sets
|
|
3
3
|
// subscriber delivery urgency; `final` drives the server-side lifecycle
|
|
4
4
|
// transition inside feed `push()` (the sanctioned path). `GET
|
|
5
|
-
// /nodes/{id}/reports` lists the node's stored report files.
|
|
5
|
+
// /nodes/{id}/reports` lists the node's stored report files. `POST
|
|
6
|
+
// /nodes/{id}/result` submits a structured result against the node's pending
|
|
7
|
+
// output schema (`crtr push result`): validate → write context/result.json →
|
|
8
|
+
// push the mode-matched report → clear the schema — or declines it
|
|
9
|
+
// (`--decline --code [--retryable]`): write context/declined.json → push the
|
|
10
|
+
// same report shape carrying a `failure`/`declined` outcome whose detail holds
|
|
11
|
+
// the reason, code, and retryable flag → clear the schema.
|
|
6
12
|
//
|
|
7
13
|
// `{id}` is the REPORTING node (client-side = CRTR_NODE_ID).
|
|
8
14
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
@@ -15,6 +21,7 @@ import { notFound, usage } from '../../../core/errors.js';
|
|
|
15
21
|
import { InputError } from '../../../core/io.js';
|
|
16
22
|
import { parseFrontmatterGeneric } from '../../../core/frontmatter.js';
|
|
17
23
|
import { toReportResultDTO } from '../map.js';
|
|
24
|
+
import { outputSchemaPath, readOutputRequest, removeOutputSchema, validateStructuredResult, writeOutputDeclined, writeOutputResult, } from '../../../core/runtime/structured-output.js';
|
|
18
25
|
function requireNode(id) {
|
|
19
26
|
if (getNode(id) === null)
|
|
20
27
|
throw notFound(`unknown node: ${id}`, { received: id });
|
|
@@ -45,46 +52,202 @@ async function handlePush(ctx) {
|
|
|
45
52
|
const id = ctx.params['id'];
|
|
46
53
|
requireNode(id);
|
|
47
54
|
const req = parsePushBody(ctx.body);
|
|
55
|
+
// A valid pending structured-output request means this node owes a
|
|
56
|
+
// schema-checked result — a plain `push final` would finish it with
|
|
57
|
+
// context/result.json never written (the stop guard honors `pushedFinal`
|
|
58
|
+
// before its structured-output check, so it cannot catch this). An INVALID
|
|
59
|
+
// request file deliberately does NOT block: the stop guard's prescribed
|
|
60
|
+
// manual-cleanup escape (`push final` after removing the corrupt file, or
|
|
61
|
+
// despite it) must stay reachable.
|
|
62
|
+
if (req.tier === 'final' && readOutputRequest(id).state === 'valid') {
|
|
63
|
+
throw new InputError({
|
|
64
|
+
error: 'structured_result_pending',
|
|
65
|
+
message: 'This node owes a structured result — a plain `push final` would finish it without recording one.',
|
|
66
|
+
next: 'Submit the result with `crtr push result` instead; it validates against the pending schema, records it, and pushes the final report for you. If the schema cannot be honestly satisfied, `crtr push result --decline "<reason>" --code <token>` records the decline and finishes the node.',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
48
69
|
// `final` flips the node to done inside push() (commitCanonicalFinal). Capture
|
|
49
70
|
// the pre-push status so the DTO can report the transition (spec §7.4).
|
|
50
71
|
const before = req.tier === 'final' ? getNode(id)?.status : undefined;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
const autoDroppedPath = req.tier === 'final' ? await guardFinalWorktree(id) : undefined;
|
|
73
|
+
const result = await pushOrRethrow(id, req);
|
|
74
|
+
const transitioned = req.tier === 'final' && before !== undefined
|
|
75
|
+
? { from: before, to: getNode(id)?.status ?? 'done' }
|
|
76
|
+
: undefined;
|
|
77
|
+
return { status: 200, body: toReportResultDTO(result, transitioned, autoDroppedPath) };
|
|
78
|
+
}
|
|
79
|
+
/** The `final`-path managed-worktree guard, BEFORE the push/transition (spec
|
|
80
|
+
* §6.2 — worktree fs ops are server-side). A provably-clean, zero-commit
|
|
81
|
+
* worktree is auto-dropped so a read-only or already-landed node
|
|
82
|
+
* can finish without an explicit `node worktree close`; a clean worktree
|
|
83
|
+
* whose exact tip is already on origin can also finish while retaining its
|
|
84
|
+
* branch and checkout. Any work that is neither locally landed nor remotely
|
|
85
|
+
* retained instead blocks with the parity `open_managed_worktree` error,
|
|
86
|
+
* round-tripped via CrtrError.details so the CLI renders the same InputError
|
|
87
|
+
* (next-hint included) it surfaced before B-5 re-homed this to the server.
|
|
88
|
+
* Returns the auto-dropped checkout path, or undefined when nothing dropped. */
|
|
89
|
+
async function guardFinalWorktree(id) {
|
|
90
|
+
if (!hasOpenManagedWorktree(getNode(id)))
|
|
91
|
+
return undefined;
|
|
92
|
+
const dropped = autoDropCleanManagedWorktree(id);
|
|
93
|
+
if (dropped !== null)
|
|
94
|
+
return dropped.worktree_path;
|
|
95
|
+
if (await branchIsPushedAtCurrentTip(getNode(id).managed_worktree))
|
|
96
|
+
return undefined;
|
|
97
|
+
// Thrown as InputError so `toErrorBody` round-trips the FULL structured
|
|
98
|
+
// payload (code + message + next) into `ApiError.details`, and the CLI's
|
|
99
|
+
// shared `apiErrorToCliError` reconstructs it byte-for-byte — no
|
|
100
|
+
// command-local mapper needed (plan C-5).
|
|
101
|
+
throw new InputError({
|
|
102
|
+
error: 'open_managed_worktree',
|
|
103
|
+
message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
|
|
104
|
+
next: (() => {
|
|
105
|
+
const wt = getNode(id)?.managed_worktree;
|
|
106
|
+
return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
|
|
107
|
+
? missingWorktreeRecoveryHint(wt)
|
|
108
|
+
: 'Commit the remaining work in the worktree, push its branch to origin at its current tip, then rerun `crtr push final`; or run `crtr node worktree close` to land it locally.';
|
|
109
|
+
})(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Cap on the fenced-JSON echo inside the submit report body. The full result
|
|
113
|
+
* is always on disk at result_path; the report only needs enough to orient. */
|
|
114
|
+
const REPORT_JSON_ECHO_LIMIT = 2000;
|
|
115
|
+
function submitReportBody(resultPath, value) {
|
|
116
|
+
const json = JSON.stringify(value, null, 2);
|
|
117
|
+
const fenced = json.length > REPORT_JSON_ECHO_LIMIT
|
|
118
|
+
? `${json.slice(0, REPORT_JSON_ECHO_LIMIT)}\n… (truncated — the full result is at the result path above)`
|
|
119
|
+
: json;
|
|
120
|
+
return `Structured output submitted.\n\nResult path: ${resultPath}\n\n\`\`\`json\n${fenced}\n\`\`\`\n`;
|
|
121
|
+
}
|
|
122
|
+
function declineReportBody(declinedPath, declined) {
|
|
123
|
+
return `Structured result declined.\n\nReason: ${declined.reason}\nCode: ${declined.code}\nRetryable: ${declined.retryable ? 'yes' : 'no'}\n\nDeclined marker: ${declinedPath}\n`;
|
|
124
|
+
}
|
|
125
|
+
/** A decline reason is one sentence for a human or a consumer to read, not a payload. */
|
|
126
|
+
const DECLINE_REASON_MAX_CHARS = 1024;
|
|
127
|
+
/** A decline code is one token the requester classifies on, not a message. */
|
|
128
|
+
const DECLINE_CODE_MAX_CHARS = 64;
|
|
129
|
+
/** Narrow the submit body to exactly one of `{ value }` or `{ decline, code, retryable }`. */
|
|
130
|
+
function parseSubmitBody(body) {
|
|
131
|
+
if (typeof body !== 'object' || body === null) {
|
|
132
|
+
throw usage('a result submission requires a JSON body { value } or { decline, code, retryable }');
|
|
133
|
+
}
|
|
134
|
+
const hasValue = Object.hasOwn(body, 'value');
|
|
135
|
+
const hasDecline = Object.hasOwn(body, 'decline');
|
|
136
|
+
if (hasValue && hasDecline)
|
|
137
|
+
throw usage('a result submission carries either { value } or { decline, code, retryable }, not both');
|
|
138
|
+
if (hasDecline) {
|
|
139
|
+
const { decline, code, retryable } = body;
|
|
140
|
+
if (typeof decline !== 'string' || decline.trim() === '') {
|
|
141
|
+
throw new InputError({
|
|
142
|
+
error: 'missing_decline_reason',
|
|
143
|
+
message: 'A decline needs a reason.',
|
|
144
|
+
next: 'Rerun with `--decline "<one sentence on why the schema cannot be honestly satisfied>" --code <token>`.',
|
|
145
|
+
});
|
|
65
146
|
}
|
|
66
|
-
|
|
67
|
-
// Thrown as InputError so `toErrorBody` round-trips the FULL structured
|
|
68
|
-
// payload (code + message + next) into `ApiError.details`, and the CLI's
|
|
69
|
-
// shared `apiErrorToCliError` reconstructs it byte-for-byte — no
|
|
70
|
-
// command-local mapper needed (plan C-5).
|
|
147
|
+
if (decline.length > DECLINE_REASON_MAX_CHARS) {
|
|
71
148
|
throw new InputError({
|
|
72
|
-
error: '
|
|
73
|
-
message:
|
|
74
|
-
next:
|
|
75
|
-
const wt = getNode(id)?.managed_worktree;
|
|
76
|
-
return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
|
|
77
|
-
? missingWorktreeRecoveryHint(wt)
|
|
78
|
-
: 'Commit the remaining work in the worktree, push its branch to origin at its current tip, then rerun `crtr push final`; or run `crtr node worktree close` to land it locally.';
|
|
79
|
-
})(),
|
|
149
|
+
error: 'decline_reason_too_long',
|
|
150
|
+
message: `A decline reason is at most ${DECLINE_REASON_MAX_CHARS} characters (got ${decline.length}).`,
|
|
151
|
+
next: 'Keep the reason to one plain sentence; put any detail in an artifact and name its path.',
|
|
80
152
|
});
|
|
81
153
|
}
|
|
154
|
+
if (typeof code !== 'string' || code.trim() === '' || /\s/.test(code.trim()) || code.trim().length > DECLINE_CODE_MAX_CHARS) {
|
|
155
|
+
throw new InputError({
|
|
156
|
+
error: 'invalid_decline_code',
|
|
157
|
+
message: `A decline needs a code: one whitespace-free token of at most ${DECLINE_CODE_MAX_CHARS} characters naming the decline class.`,
|
|
158
|
+
next: 'Rerun with `--code <token>` — the token your requester documented for this case.',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (typeof retryable !== 'boolean') {
|
|
162
|
+
throw usage('a decline carries `retryable` as a boolean');
|
|
163
|
+
}
|
|
164
|
+
return { declined: { reason: decline.trim(), code: code.trim(), retryable } };
|
|
82
165
|
}
|
|
83
|
-
|
|
84
|
-
|
|
166
|
+
if (!hasValue) {
|
|
167
|
+
// `value` must be PRESENT — but any JSON value, including null, is a legal
|
|
168
|
+
// result to validate, so presence is checked rather than truthiness.
|
|
169
|
+
throw usage('a result submission requires a JSON body { value } or { decline, code, retryable }');
|
|
170
|
+
}
|
|
171
|
+
return { value: body.value };
|
|
172
|
+
}
|
|
173
|
+
/** `POST /v1/nodes/{id}/result` — submit a structured result (`crtr push
|
|
174
|
+
* result`). Validate against the pending schema, write context/result.json
|
|
175
|
+
* atomically, push the mode-matched report (final for terminal, update for
|
|
176
|
+
* oneoff), then clear the schema. Any failure before the schema removal leaves
|
|
177
|
+
* the request in place, so the submit stays retryable. */
|
|
178
|
+
async function handleSubmitResult(ctx) {
|
|
179
|
+
const id = ctx.params['id'];
|
|
180
|
+
requireNode(id);
|
|
181
|
+
const submitted = parseSubmitBody(ctx.body);
|
|
182
|
+
const state = readOutputRequest(id);
|
|
183
|
+
if (state.state === 'absent') {
|
|
184
|
+
throw new InputError({
|
|
185
|
+
error: 'no_output_request',
|
|
186
|
+
message: 'No structured output is currently requested of this node.',
|
|
187
|
+
next: 'Nothing to submit here — continue working, or finish with `crtr push final`.',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (state.state === 'invalid') {
|
|
191
|
+
throw new InputError({
|
|
192
|
+
error: 'invalid_output_request',
|
|
193
|
+
message: `The structured-output request file is invalid: ${state.error}`,
|
|
194
|
+
next: `Have the requester re-issue the request, or remove ${outputSchemaPath(id)} and finish with \`crtr push final\`.`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const { mode, schema } = state.request;
|
|
198
|
+
let validated;
|
|
199
|
+
if ('value' in submitted) {
|
|
200
|
+
try {
|
|
201
|
+
validated = await validateStructuredResult(schema, submitted.value);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
// Schema retained — the node fixes the listed fields and resubmits.
|
|
205
|
+
throw new InputError({
|
|
206
|
+
error: 'result_schema_mismatch',
|
|
207
|
+
message: `the submitted result does not match the requested schema: ${err.message}`,
|
|
208
|
+
next: 'Fix the listed fields and rerun `crtr push result`; if the schema cannot be honestly satisfied, rerun with `--decline "<reason>" --code <token>`.',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const before = mode === 'terminal' ? getNode(id)?.status : undefined;
|
|
213
|
+
const autoDroppedPath = mode === 'terminal' ? await guardFinalWorktree(id) : undefined;
|
|
214
|
+
let resultPath;
|
|
215
|
+
let reportBody;
|
|
216
|
+
let outcome;
|
|
217
|
+
if ('declined' in submitted) {
|
|
218
|
+
const { declined } = submitted;
|
|
219
|
+
resultPath = writeOutputDeclined(id, { mode, ...declined, at: new Date().toISOString() });
|
|
220
|
+
reportBody = declineReportBody(resultPath, declined);
|
|
221
|
+
// A one-off decline is an update report, not the canonical final: no outcome to record.
|
|
222
|
+
if (mode === 'terminal') {
|
|
223
|
+
const detail = { schema: 'crtr.node-outcome-detail/v1', message: declined.reason, declined };
|
|
224
|
+
outcome = { kind: 'failure', reason: 'declined', detailJson: JSON.stringify(detail) };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
resultPath = writeOutputResult(id, validated);
|
|
229
|
+
reportBody = submitReportBody(resultPath, validated);
|
|
230
|
+
}
|
|
231
|
+
const pushed = await pushOrRethrow(id, {
|
|
232
|
+
tier: mode === 'terminal' ? 'final' : 'update',
|
|
233
|
+
body: reportBody,
|
|
234
|
+
}, outcome);
|
|
235
|
+
removeOutputSchema(id);
|
|
236
|
+
const transitioned = mode === 'terminal' && before !== undefined
|
|
85
237
|
? { from: before, to: getNode(id)?.status ?? 'done' }
|
|
86
238
|
: undefined;
|
|
87
|
-
|
|
239
|
+
const body = {
|
|
240
|
+
mode,
|
|
241
|
+
result_path: resultPath,
|
|
242
|
+
report_path: pushed.reportPath,
|
|
243
|
+
notified: [...pushed.deliveredTo],
|
|
244
|
+
...('declined' in submitted ? { declined: submitted.declined } : {}),
|
|
245
|
+
...(transitioned === undefined ? {} : { transitioned }),
|
|
246
|
+
...(autoDroppedPath === undefined
|
|
247
|
+
? {}
|
|
248
|
+
: { worktree_auto_dropped: true, worktree_auto_dropped_path: autoDroppedPath }),
|
|
249
|
+
};
|
|
250
|
+
return { status: 200, body };
|
|
88
251
|
}
|
|
89
252
|
/** `push()` but with the finalization guards translated to the same structured
|
|
90
253
|
* `InputError` the pre-API `crtr push` verb surfaced. Feed throws a bare
|
|
@@ -95,9 +258,9 @@ async function handlePush(ctx) {
|
|
|
95
258
|
* survives (a bare 409 would otherwise collapse to the generic `conflict`
|
|
96
259
|
* code). `unknown_node` is unreachable here (`requireNode` guards above), so
|
|
97
260
|
* it falls through as the generic 404. */
|
|
98
|
-
async function pushOrRethrow(id, req) {
|
|
261
|
+
async function pushOrRethrow(id, req, outcome) {
|
|
99
262
|
try {
|
|
100
|
-
return await push(id, { kind: req.tier, body: req.body, ...(req.delivery_tier === undefined ? {} : { deliveryTier: req.delivery_tier }) });
|
|
263
|
+
return await push(id, { kind: req.tier, body: req.body, ...(req.delivery_tier === undefined ? {} : { deliveryTier: req.delivery_tier }), ...(outcome === undefined ? {} : { outcome }) });
|
|
101
264
|
}
|
|
102
265
|
catch (err) {
|
|
103
266
|
if (err instanceof FinalizationError && err.code === 'already_finalized') {
|
|
@@ -164,4 +327,5 @@ function handleList(ctx) {
|
|
|
164
327
|
export const reportRoutes = [
|
|
165
328
|
{ method: 'POST', pattern: '/v1/nodes/:id/reports', handler: handlePush },
|
|
166
329
|
{ method: 'GET', pattern: '/v1/nodes/:id/reports', handler: handleList },
|
|
330
|
+
{ method: 'POST', pattern: '/v1/nodes/:id/result', handler: handleSubmitResult },
|
|
167
331
|
];
|
package/dist/daemon/api/map.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ManagedWorktree, NodeMeta, NodeRow, SubscriptionRef } from '../../core/canvas/types.js';
|
|
1
|
+
import type { ManagedWorktree, NodeMeta, NodeRow, NodeRuntime, SubscriptionRef } from '../../core/canvas/types.js';
|
|
2
|
+
import type { OutcomeDeliveryRecord } from '../../core/canvas/node-outcome-deliveries.js';
|
|
2
3
|
import type { ReviewComment, ReviewCommentEvent, ReviewErrorCode, ReviewRecord, ReviewResult } from '../../core/review/types.js';
|
|
3
4
|
import type { Cron, CronRunRecord } from '../../core/canvas/crons.js';
|
|
4
5
|
import type { ProfileEntry } from '../../core/profiles/manifest.js';
|
|
@@ -6,7 +7,11 @@ import type { ReviveResult } from '../../core/runtime/revive.js';
|
|
|
6
7
|
import type { CloseNodeResult } from '../../core/runtime/close.js';
|
|
7
8
|
import { type PushResult } from '../../core/feed/feed.js';
|
|
8
9
|
import type { PageFeedback, PageFeedbackComment } from '../../core/human/feedback.js';
|
|
9
|
-
import type { CloseResultDTO, CronDTO, CronRunDTO, ErrorBody, NodeEdgesDTO, NodePathsDTO, NodeStatusDTO, NodeSummaryDTO, NodeDetailDTO, NodeWorktreeDTO, ProfileDTO, ReviewCommentDTO, ReviewCommentEventDTO, ReviewDTO, FeedbackResultDTO, PageFeedbackCommentDTO, PageFeedbackDTO, PushReportResultDTO, ReviveResultDTO, SubscriptionDTO } from '../../api/index.js';
|
|
10
|
+
import type { CloseResultDTO, CronDTO, CronRunDTO, ErrorBody, NodeEdgesDTO, NodeOutcomeDTO, NodePathsDTO, NodeStatusDTO, OutcomeDeliveryDTO, NodeSummaryDTO, NodeDetailDTO, NodeWorktreeDTO, ProfileDTO, ReviewCommentDTO, ReviewCommentEventDTO, ReviewDTO, FeedbackResultDTO, PageFeedbackCommentDTO, PageFeedbackDTO, PushReportResultDTO, ReviveResultDTO, SubscriptionDTO } from '../../api/index.js';
|
|
11
|
+
/** Project a latched outcome, or null while this node remains unsettled. */
|
|
12
|
+
export declare function toNodeOutcomeDTO(node: Pick<NodeMeta, 'node_id' | 'final_report'> & NodeRuntime): NodeOutcomeDTO | null;
|
|
13
|
+
/** Project a durable delivery row without exposing its execution coordinates or stderr tail. */
|
|
14
|
+
export declare function toOutcomeDeliveryDTO(row: OutcomeDeliveryRecord): OutcomeDeliveryDTO;
|
|
10
15
|
/** Project the queryable row (what `listNodes` yields) into a summary DTO. */
|
|
11
16
|
export declare function toNodeSummaryDTO(row: NodeRow): NodeSummaryDTO;
|
|
12
17
|
/** The subscription/child edges a detail projection needs. The handler gathers
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
// dependency-light wire contract. `src/api/*` stays pure (Node built-ins only);
|
|
5
5
|
// all projection lives here, server-side.
|
|
6
6
|
//
|
|
7
|
-
// Every function here is a pure projection (no
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Every function here is a pure projection (no db reach) except the path
|
|
8
|
+
// builders and the optional structured-result sidecar read needed by a settled
|
|
9
|
+
// outcome. The DTO field sets these project into were pinned by A-1 as
|
|
10
10
|
// reasonable projections; this module is what makes them concrete, so any DTO
|
|
11
11
|
// field that projection proves wrong is corrected here at the source.
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { outputResultPath } from '../../core/runtime/structured-output.js';
|
|
12
14
|
import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
|
|
13
|
-
import { sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
|
|
15
|
+
import { isProfilePaused, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
|
|
14
16
|
import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
|
|
15
17
|
import { FinalizationError } from '../../core/feed/feed.js';
|
|
16
18
|
import { NodeIdConflictError } from '../../core/runtime/nodes.js';
|
|
@@ -23,6 +25,72 @@ import { sinkDisplay } from '../cron-sink.js';
|
|
|
23
25
|
import { responsePath } from '../../core/human/convention.js';
|
|
24
26
|
import { ticketDir } from '../../core/human/root.js';
|
|
25
27
|
// Node projections
|
|
28
|
+
/** Read the optional machine result without letting a missing or malformed sidecar obscure the durable outcome row. */
|
|
29
|
+
function readStructuredResult(nodeId) {
|
|
30
|
+
const path = outputResultPath(nodeId);
|
|
31
|
+
try {
|
|
32
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Project a latched outcome, or null while this node remains unsettled. */
|
|
39
|
+
export function toNodeOutcomeDTO(node) {
|
|
40
|
+
if (node.outcome_at === null || node.outcome_at === undefined || node.outcome_kind === null || node.outcome_kind === undefined || node.outcome_reason === null || node.outcome_reason === undefined) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
let detail = null;
|
|
44
|
+
if (node.outcome_detail_json !== null && node.outcome_detail_json !== undefined) {
|
|
45
|
+
try {
|
|
46
|
+
detail = JSON.parse(node.outcome_detail_json);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
detail = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// A declined structured result is a `failure` that still carries a canonical final report.
|
|
53
|
+
const finalReport = node.final_report ?? null;
|
|
54
|
+
const base = {
|
|
55
|
+
node_id: node.node_id,
|
|
56
|
+
revision: node.outcome_revision ?? 1,
|
|
57
|
+
settled_at: node.outcome_at,
|
|
58
|
+
final_report: finalReport,
|
|
59
|
+
final_report_path: finalReport === null ? null : `${reportsDir(node.node_id)}/${finalReport}`,
|
|
60
|
+
structured_result: node.outcome_kind === 'result' ? readStructuredResult(node.node_id) : null,
|
|
61
|
+
detail,
|
|
62
|
+
};
|
|
63
|
+
if (node.outcome_kind === 'result')
|
|
64
|
+
return { ...base, kind: 'result', reason: node.outcome_reason, declined: null };
|
|
65
|
+
if (node.outcome_reason === 'declined') {
|
|
66
|
+
// The decline lane always records its detail; a declined row without one is corrupt, not a legacy shape.
|
|
67
|
+
if (detail?.declined === undefined)
|
|
68
|
+
throw new Error(`node ${node.node_id}: outcome reason 'declined' without a declined detail`);
|
|
69
|
+
return { ...base, kind: 'failure', reason: 'declined', declined: detail.declined };
|
|
70
|
+
}
|
|
71
|
+
return { ...base, kind: 'failure', reason: node.outcome_reason, declined: null };
|
|
72
|
+
}
|
|
73
|
+
/** Project a durable delivery row without exposing its execution coordinates or stderr tail. */
|
|
74
|
+
export function toOutcomeDeliveryDTO(row) {
|
|
75
|
+
const failure = row.lastFailure;
|
|
76
|
+
return {
|
|
77
|
+
node_id: row.nodeId,
|
|
78
|
+
state: row.state,
|
|
79
|
+
action: row.actionName,
|
|
80
|
+
attempt: row.attempt,
|
|
81
|
+
next_attempt_at: row.nextAttemptAt === 0 ? null : new Date(row.nextAttemptAt).toISOString(),
|
|
82
|
+
accepted_at: row.acceptedAt === null ? null : new Date(row.acceptedAt).toISOString(),
|
|
83
|
+
permanent_failed_at: row.permanentFailedAt === null ? null : new Date(row.permanentFailedAt).toISOString(),
|
|
84
|
+
last_failure: failure === null ? null : {
|
|
85
|
+
kind: failure.kind,
|
|
86
|
+
...(failure.exitCode === undefined ? {} : { exit_code: failure.exitCode }),
|
|
87
|
+
...(failure.signal === undefined ? {} : { signal: failure.signal }),
|
|
88
|
+
...(failure.message === undefined ? {} : { message: failure.message }),
|
|
89
|
+
},
|
|
90
|
+
created_at: new Date(row.createdAt).toISOString(),
|
|
91
|
+
updated_at: new Date(row.updatedAt).toISOString(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
26
94
|
/** Project the queryable row (what `listNodes` yields) into a summary DTO. */
|
|
27
95
|
export function toNodeSummaryDTO(row) {
|
|
28
96
|
return {
|
|
@@ -37,6 +105,7 @@ export function toNodeSummaryDTO(row) {
|
|
|
37
105
|
cwd: row.cwd,
|
|
38
106
|
host_kind: row.host_kind,
|
|
39
107
|
profile_id: row.profile_id,
|
|
108
|
+
profile_paused: isProfilePaused(row.profile_id),
|
|
40
109
|
parent: row.parent,
|
|
41
110
|
created: row.created,
|
|
42
111
|
intent: row.intent,
|
|
@@ -50,6 +119,8 @@ export function toNodeSummaryDTO(row) {
|
|
|
50
119
|
pane: row.pane,
|
|
51
120
|
final_report: row.final_report ?? null,
|
|
52
121
|
finalized_at: row.finalized_at ?? null,
|
|
122
|
+
deadline_at: row.deadline_at ?? null,
|
|
123
|
+
outcome: toNodeOutcomeDTO(row),
|
|
53
124
|
};
|
|
54
125
|
}
|
|
55
126
|
/** Project a node's spine + subscription edges (absorbs `managers`/`paths`). */
|
|
@@ -109,6 +180,7 @@ export function toNodeDetailDTO(meta, edges) {
|
|
|
109
180
|
cwd: meta.cwd,
|
|
110
181
|
host_kind: meta.host_kind ?? null,
|
|
111
182
|
profile_id: meta.profile_id ?? null,
|
|
183
|
+
profile_paused: isProfilePaused(meta.profile_id),
|
|
112
184
|
parent: meta.parent ?? null,
|
|
113
185
|
created: meta.created,
|
|
114
186
|
intent: (meta.intent ?? null),
|
|
@@ -122,6 +194,8 @@ export function toNodeDetailDTO(meta, edges) {
|
|
|
122
194
|
pane: meta.pane ?? null,
|
|
123
195
|
final_report: meta.final_report ?? null,
|
|
124
196
|
finalized_at: meta.finalized_at ?? null,
|
|
197
|
+
deadline_at: meta.deadline_at ?? null,
|
|
198
|
+
outcome: toNodeOutcomeDTO(meta),
|
|
125
199
|
creator: meta.creator ?? null,
|
|
126
200
|
edges: toNodeEdgesDTO(meta, edges),
|
|
127
201
|
paths: toNodePathsDTO(meta.node_id),
|
|
@@ -38,6 +38,7 @@ import { messageRoutes } from './handlers/messages.js';
|
|
|
38
38
|
import { modelAuthRoutes } from './handlers/modelauth.js';
|
|
39
39
|
import { modelConfigRoutes } from './handlers/model-config.js';
|
|
40
40
|
import { nodeRoutes } from './handlers/nodes.js';
|
|
41
|
+
import { nodeOutcomeRoutes } from './handlers/node-outcomes.js';
|
|
41
42
|
import { profileRoutes } from './handlers/profiles.js';
|
|
42
43
|
import { prospectiveChatInventoryRoutes } from './handlers/prospective-chat-inventory.js';
|
|
43
44
|
import { reportRoutes } from './handlers/reports.js';
|
|
@@ -54,6 +55,7 @@ function buildRouter() {
|
|
|
54
55
|
.registerAll(healthRoutes)
|
|
55
56
|
.registerAll(daemonRoutes)
|
|
56
57
|
.registerAll(nodeRoutes)
|
|
58
|
+
.registerAll(nodeOutcomeRoutes)
|
|
57
59
|
.registerAll(bashJobRoutes)
|
|
58
60
|
.registerAll(brokerOperationRoutes)
|
|
59
61
|
.registerAll(worktreeRoutes)
|