@north-light/crouter 0.3.213 → 0.3.215
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/dto/inbox.d.ts +19 -0
- package/dist/api/dto/nodes.d.ts +2 -1
- package/dist/api/dto/profiles.d.ts +13 -1
- package/dist/builtin-memory/00-runtime-base.md +12 -11
- package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
- package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
- package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
- package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
- package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
- package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
- package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
- package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
- package/dist/clients/attach/__tests__/context-message.test.js +33 -5
- package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
- package/dist/clients/attach/chrome/canvas-panels.js +5 -7
- package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
- package/dist/clients/attach/chrome/inbox-strip.js +187 -0
- package/dist/clients/attach/chrome/roster.d.ts +6 -2
- package/dist/clients/attach/chrome/roster.js +13 -52
- package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
- package/dist/clients/attach/chrome/ticket-panel.js +236 -0
- package/dist/clients/attach/render/card-presentation.d.ts +8 -3
- package/dist/clients/attach/render/card-presentation.js +48 -10
- package/dist/clients/attach/render/context-message.d.ts +5 -0
- package/dist/clients/attach/render/context-message.js +20 -14
- package/dist/clients/attach/session/context.d.ts +3 -0
- package/dist/clients/attach/session/frame.d.ts +4 -0
- package/dist/clients/attach/session/frame.js +8 -3
- package/dist/clients/attach/session/keys.d.ts +7 -0
- package/dist/clients/attach/session/keys.js +7 -0
- package/dist/clients/attach/session/layout.js +6 -3
- package/dist/clients/attach/session/pane-focus.d.ts +8 -0
- package/dist/clients/attach/session/pane-focus.js +42 -0
- package/dist/clients/attach/viewer.js +794 -790
- package/dist/clients/inbox/controller.d.ts +9 -0
- package/dist/clients/inbox/controller.js +57 -2
- package/dist/clients/inbox/surface.d.ts +2 -0
- package/dist/clients/inbox/surface.js +18 -3
- package/dist/clients/inbox/tui.d.ts +2 -0
- package/dist/clients/inbox/tui.js +11 -1
- package/dist/commands/node/create.js +3 -3
- package/dist/commands/profile/kind.d.ts +2 -0
- package/dist/commands/profile/kind.js +51 -0
- package/dist/commands/profile/list.js +5 -1
- package/dist/commands/profile/meta.d.ts +4 -0
- package/dist/commands/profile/meta.js +67 -0
- package/dist/commands/profile/new.js +33 -3
- package/dist/commands/profile/pause.d.ts +2 -0
- package/dist/commands/profile/pause.js +60 -0
- package/dist/commands/profile/show.js +9 -1
- package/dist/commands/profile.js +5 -8
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
- package/dist/core/__tests__/dead-node-policy-table.test.js +13 -0
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
- package/dist/core/command.js +23 -23
- package/dist/core/feed/inbox.js +1 -6
- package/dist/core/help.d.ts +1 -1
- package/dist/core/human/component-docs.js +3 -2
- package/dist/core/human/page-schema.d.ts +28 -0
- package/dist/core/human/page-schema.js +35 -2
- package/dist/core/human/scan.js +7 -1
- package/dist/core/human/types.d.ts +4 -0
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/profiles/deletion-reservation.d.ts +7 -3
- package/dist/core/profiles/deletion-reservation.js +9 -5
- package/dist/core/profiles/manifest.d.ts +27 -1
- package/dist/core/profiles/manifest.js +122 -4
- package/dist/core/runtime/boot-root.js +3 -3
- package/dist/core/runtime/broker/inbox.js +1 -5
- package/dist/core/runtime/close.js +10 -5
- package/dist/core/runtime/revive.js +2 -0
- package/dist/core/runtime/spawn-env.d.ts +9 -1
- package/dist/core/runtime/spawn-env.js +17 -1
- package/dist/core/substrate/on-read.js +16 -0
- package/dist/core/termrender/termrender.d.ts +7 -2
- package/dist/core/termrender/termrender.js +11 -6
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
- package/dist/daemon/api/handlers/inbox.js +31 -41
- package/dist/daemon/api/handlers/messages.js +9 -2
- package/dist/daemon/api/handlers/nodes.d.ts +2 -0
- package/dist/daemon/api/handlers/nodes.js +15 -8
- package/dist/daemon/api/handlers/profiles.js +7 -2
- package/dist/daemon/api/map.js +3 -0
- package/dist/daemon/fleet.d.ts +8 -4
- package/dist/daemon/fleet.js +37 -9
- package/dist/daemon/reconcilers/broker-supervision.js +15 -25
- package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
- package/dist/daemon/reconcilers/live-obligation.d.ts +14 -1
- package/dist/daemon/reconcilers/live-obligation.js +27 -18
- package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
- package/dist/shared/__tests__/generated-context-grammar.test.js +4 -6
- package/dist/shared/generated-context.d.ts +0 -4
- package/dist/shared/generated-context.js +6 -9
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
- package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
- /package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +0 -0
- /package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +0 -0
|
@@ -84,9 +84,9 @@ export function profileMemoryDir(profileId) {
|
|
|
84
84
|
* the user started them, not in whatever dir was last added to its purview. */
|
|
85
85
|
function normalizeHome(profileId, manifest) {
|
|
86
86
|
const stored = typeof manifest.home === 'string' && manifest.home !== '' ? manifest.home : null;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return { ...manifest, home:
|
|
87
|
+
const home = profileId === ROOT_PROFILE_ID ? stored : stored ?? manifest.projects[0] ?? null;
|
|
88
|
+
const pausedAt = typeof manifest.paused_at === 'string' && manifest.paused_at !== '' ? manifest.paused_at : null;
|
|
89
|
+
return { ...manifest, home, paused_at: pausedAt };
|
|
90
90
|
}
|
|
91
91
|
function readManifestFile(path, profileId) {
|
|
92
92
|
if (!existsSync(path))
|
|
@@ -282,6 +282,7 @@ export function ensureRootProfile() {
|
|
|
282
282
|
name: ROOT_PROFILE_NAME,
|
|
283
283
|
projects: [],
|
|
284
284
|
home: null,
|
|
285
|
+
paused_at: null,
|
|
285
286
|
created_at: nowIso(),
|
|
286
287
|
last_used_at: nowIso(),
|
|
287
288
|
};
|
|
@@ -290,10 +291,33 @@ export function ensureRootProfile() {
|
|
|
290
291
|
return { profileId: ROOT_PROFILE_ID, manifest: next };
|
|
291
292
|
});
|
|
292
293
|
}
|
|
293
|
-
|
|
294
|
+
/** Key shape every stored metadata entry must satisfy — kept env-mappable
|
|
295
|
+
* (`CRTR_PROFILE_META_<KEY>`, see `core/runtime/spawn-env.ts`) by
|
|
296
|
+
* construction. */
|
|
297
|
+
const METADATA_KEY_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
298
|
+
export function assertProfileMetadata(entries) {
|
|
299
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
300
|
+
if (!METADATA_KEY_SHAPE.test(key)) {
|
|
301
|
+
throw usage(`invalid metadata key: ${JSON.stringify(key)}`, {
|
|
302
|
+
received: key,
|
|
303
|
+
field: 'metadata',
|
|
304
|
+
next: 'Keys start with a letter or digit, followed by letters, digits, `_`, or `-`.',
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (value.includes('\0')) {
|
|
308
|
+
throw usage(`metadata value for ${key} contains a NUL byte, which no OS accepts in an environment variable`, {
|
|
309
|
+
field: 'metadata',
|
|
310
|
+
next: 'Remove NUL bytes from the value and retry.',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export function createProfile(name, projects = [], opts = {}) {
|
|
294
316
|
const trimmed = name.trim();
|
|
295
317
|
if (trimmed === '')
|
|
296
318
|
throw usage('profile name must not be empty');
|
|
319
|
+
if (opts.metadata !== undefined)
|
|
320
|
+
assertProfileMetadata(opts.metadata);
|
|
297
321
|
const resolvedProjects = dedupeOrdered(projects.map(resolveExistingProjectDir));
|
|
298
322
|
const profileId = generateProfileId(trimmed);
|
|
299
323
|
return withProfileManifestLock(profileId, () => {
|
|
@@ -302,6 +326,9 @@ export function createProfile(name, projects = []) {
|
|
|
302
326
|
name: trimmed,
|
|
303
327
|
projects: resolvedProjects,
|
|
304
328
|
home: resolvedProjects[0] ?? null,
|
|
329
|
+
paused_at: null,
|
|
330
|
+
...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
|
|
331
|
+
...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
|
|
305
332
|
created_at: nowIso(),
|
|
306
333
|
last_used_at: null,
|
|
307
334
|
};
|
|
@@ -335,6 +362,97 @@ function mutateManifest(profileId, mutate) {
|
|
|
335
362
|
export function updateProfileLastUsed(profileId) {
|
|
336
363
|
return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
|
|
337
364
|
}
|
|
365
|
+
/** Resolve an existing profile for a launch gate without making stale durable
|
|
366
|
+
* profile ids a new failure mode on a hot path. */
|
|
367
|
+
function loadProfileManifestOrNull(profileId) {
|
|
368
|
+
if (profileId === null || profileId === undefined || profileId === '')
|
|
369
|
+
return null;
|
|
370
|
+
try {
|
|
371
|
+
return loadProfileManifest(profileId);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Paused check for an ALREADY-RESOLVED durable id (a canvas row's `profile_id`,
|
|
378
|
+
* `CRTR_PROFILE_ID`) — exact-id only, no name matching, so it costs one small
|
|
379
|
+
* read on the hot delivery paths that call it every poll. Fails OPEN: an id
|
|
380
|
+
* whose manifest is missing or corrupt is not paused, because treating it as
|
|
381
|
+
* paused would strand that node's queued inbox forever. */
|
|
382
|
+
export function isProfilePaused(profileId) {
|
|
383
|
+
if (profileId === null || profileId === undefined || profileId === '')
|
|
384
|
+
return false;
|
|
385
|
+
try {
|
|
386
|
+
return loadExactProfileManifest(profileId).manifest.paused_at !== null;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** Refuse a launch under a paused profile, naming the resume command. Shared by
|
|
393
|
+
* every gate that already holds a loaded manifest. */
|
|
394
|
+
export function assertProfileEntryActive(entry) {
|
|
395
|
+
if (entry.manifest.paused_at === null)
|
|
396
|
+
return;
|
|
397
|
+
throw usage(`profile "${entry.manifest.name}" is paused and cannot start nodes`, {
|
|
398
|
+
received: entry.profileId,
|
|
399
|
+
next: `Resume it with \`crtr profile resume ${entry.profileId}\`.`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
export function assertProfileActive(profileId) {
|
|
403
|
+
const entry = loadProfileManifestOrNull(profileId);
|
|
404
|
+
if (entry === null)
|
|
405
|
+
return null;
|
|
406
|
+
assertProfileEntryActive(entry);
|
|
407
|
+
return entry;
|
|
408
|
+
}
|
|
409
|
+
export function pauseProfile(profileId) {
|
|
410
|
+
return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
|
|
411
|
+
}
|
|
412
|
+
export function resumeProfile(profileId) {
|
|
413
|
+
return mutateManifest(profileId, (m) => ({ ...m, paused_at: null }));
|
|
414
|
+
}
|
|
415
|
+
export function setProfileDefaultKind(profileId, defaultKind) {
|
|
416
|
+
return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
|
|
417
|
+
}
|
|
418
|
+
/** Merge `set` entries over the stored map and drop `unset` keys; the
|
|
419
|
+
* `metadata` field is omitted entirely when the result is empty. */
|
|
420
|
+
export function updateProfileMetadata(profileId, set, unset = []) {
|
|
421
|
+
assertProfileMetadata(set);
|
|
422
|
+
return mutateManifest(profileId, (m) => {
|
|
423
|
+
const next = { ...(m.metadata ?? {}), ...set };
|
|
424
|
+
for (const key of unset)
|
|
425
|
+
delete next[key];
|
|
426
|
+
const { metadata: _dropped, ...rest } = m;
|
|
427
|
+
return { ...rest, ...(Object.keys(next).length > 0 ? { metadata: next } : {}) };
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/** A profile's stored metadata for broker-env injection — sanitized entry by
|
|
431
|
+
* entry (a manifest is hand-editable, so a bad key/value is dropped rather
|
|
432
|
+
* than trusted) and never throwing: a stale/invalid/absent profile id is a
|
|
433
|
+
* hot-path no-op, because broker-env resolution must never fail a launch
|
|
434
|
+
* over a bad profile id (mirrors `readProfileEnvVars`). */
|
|
435
|
+
export function readProfileMetadata(profileId) {
|
|
436
|
+
if (profileId === null || profileId === '')
|
|
437
|
+
return {};
|
|
438
|
+
try {
|
|
439
|
+
const stored = loadExactProfileManifest(profileId).manifest.metadata ?? {};
|
|
440
|
+
const out = {};
|
|
441
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
442
|
+
if (typeof value !== 'string')
|
|
443
|
+
continue;
|
|
444
|
+
if (!METADATA_KEY_SHAPE.test(key))
|
|
445
|
+
continue;
|
|
446
|
+
if (value.includes('\0'))
|
|
447
|
+
continue;
|
|
448
|
+
out[key] = value;
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return {};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
338
456
|
export function renameProfile(profileId, name) {
|
|
339
457
|
const trimmed = name.trim();
|
|
340
458
|
if (trimmed === '')
|
|
@@ -30,7 +30,7 @@ export async function bootRoot(opts) {
|
|
|
30
30
|
if (!inTmux()) {
|
|
31
31
|
throw new Error('crtr must be started from inside a tmux session — start tmux first (e.g. `tmux new -s work`), then run `crtr` there.');
|
|
32
32
|
}
|
|
33
|
-
const kind = opts.kind
|
|
33
|
+
const kind = opts.kind;
|
|
34
34
|
// The front door's only source of profile identity — a root has no spawner to
|
|
35
35
|
// inherit from. Runs BEFORE the create call: explicit --profile > MRU profile
|
|
36
36
|
// covering cwd > a synchronous create-or-root-profile prompt (the stable root
|
|
@@ -47,12 +47,12 @@ export async function bootRoot(opts) {
|
|
|
47
47
|
// viewer paints the crouton banner the instant the engine attaches (~1s),
|
|
48
48
|
// which is the real "we're up" signal.
|
|
49
49
|
const detail = await cliClient().createNode({
|
|
50
|
-
kind,
|
|
50
|
+
...(kind !== undefined ? { kind } : {}),
|
|
51
51
|
mode: 'base',
|
|
52
52
|
prompt: opts.prompt,
|
|
53
53
|
profile: profileId,
|
|
54
54
|
cwd: opts.cwd,
|
|
55
|
-
name: opts.name
|
|
55
|
+
...(opts.name !== undefined ? { name: opts.name } : kind !== undefined ? { name: kind } : {}),
|
|
56
56
|
root: true,
|
|
57
57
|
// The front door is exactly the wait a warm spare exists to remove: a bare
|
|
58
58
|
// resident root with no kickoff. A miss costs nothing (cold spawn) and
|
|
@@ -198,11 +198,7 @@ export function coalesceBrokerInbox(entries, reportNodes) {
|
|
|
198
198
|
cardSlot = deliveries.length;
|
|
199
199
|
deliveries.push({ kind: 'card', text: '', cards: [], entries: [] });
|
|
200
200
|
}
|
|
201
|
-
|
|
202
|
-
// entries, which predate the producer's own `from_name` snapshot.
|
|
203
|
-
const name = items.map((item) => item.from_name).filter((value) => value !== undefined && value !== '').at(-1)
|
|
204
|
-
?? reportNodes.get(sender)?.name;
|
|
205
|
-
sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map((entry) => cardEntry(entry, reportNodes)) });
|
|
201
|
+
sections.push({ id: sender, entries: items.map((entry) => cardEntry(entry, reportNodes)) });
|
|
206
202
|
}
|
|
207
203
|
if (cardSlot >= 0) {
|
|
208
204
|
// Card sections group by sender, so its entry list comes from the input
|
|
@@ -46,11 +46,16 @@ import { appendPassive } from '../feed/passive.js';
|
|
|
46
46
|
* crash path (revive.ts). */
|
|
47
47
|
export function fanDoctrineWake(fromId, subscribers, label, data) {
|
|
48
48
|
for (const sub of subscribers) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
try {
|
|
50
|
+
const notice = { from: fromId, tier: 'normal', kind: 'message', label, data };
|
|
51
|
+
if (sub.active)
|
|
52
|
+
appendInbox(sub.node_id, notice);
|
|
53
|
+
else
|
|
54
|
+
appendPassive(sub.node_id, notice);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* one unavailable subscriber never blocks the rest */
|
|
58
|
+
}
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
/** The set of nodes to close: the root plus every descendant reachable down the
|
|
@@ -36,6 +36,7 @@ import { clearFault, beginBootFaultAttempt } from './fault.js';
|
|
|
36
36
|
import { clearInjectedDocs } from '../substrate/injected-store.js';
|
|
37
37
|
import { rootOfSpine } from './nodes.js';
|
|
38
38
|
import { isReviewCompanionBound } from '../review/companion.js';
|
|
39
|
+
import { assertProfileActive } from '../profiles/manifest.js';
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
// resumeArgs — which session source a revive resumes from
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
@@ -108,6 +109,7 @@ export function reviveNode(nodeId, opts) {
|
|
|
108
109
|
if (meta.final_report !== null) {
|
|
109
110
|
throw new Error(`reviveNode: refusing to revive ${nodeId} — its finalization latch is set (final_report=${meta.final_report}). A final landed between the caller's reopen-gate check and this revive; re-run with --reopen if this retask is still wanted.`);
|
|
110
111
|
}
|
|
112
|
+
assertProfileActive(meta.profile_id);
|
|
111
113
|
// Double-launch guard: a fleet entry IS liveness (design D-1). reviveNode
|
|
112
114
|
// runs fully SYNCHRONOUSLY on the daemon thread — this check, the launch,
|
|
113
115
|
// and the fleet registration inside `headlessBrokerHost.launch` complete
|
|
@@ -22,6 +22,12 @@ export declare function buildOperationalEnvBase(opts: {
|
|
|
22
22
|
targetProfileId: string | null;
|
|
23
23
|
host?: NodeJS.ProcessEnv;
|
|
24
24
|
}): NodeJS.ProcessEnv;
|
|
25
|
+
/** Source F — the TARGET profile's manifest `metadata`, each entry surfaced
|
|
26
|
+
* as `CRTR_PROFILE_META_<KEY>` (key uppercased, every non-alphanumeric run
|
|
27
|
+
* → `_`). Identity facts rather than secrets — unlike the env store these
|
|
28
|
+
* are readable back (`profile show`, ProfileDTO). Read straight off the
|
|
29
|
+
* manifest at every launch; never throws (a bad profile id yields `{}`). */
|
|
30
|
+
export declare function profileMetadataEnv(profileId: string | null): NodeJS.ProcessEnv;
|
|
25
31
|
/** The one authoritative broker child env: sources A–C (the operational
|
|
26
32
|
* base, resolved from the TARGET node's own cwd/profile — `inv.env`'s
|
|
27
33
|
* `CRTR_NODE_CWD`/`CRTR_PROFILE_ID`, never this process's ambient
|
|
@@ -30,7 +36,9 @@ export declare function buildOperationalEnvBase(opts: {
|
|
|
30
36
|
* own `profile env` store (`readProfileEnvVars`, `core/profiles/env-store.ts`)
|
|
31
37
|
* read directly off disk and injected regardless of the host env or any
|
|
32
38
|
* `spawnEnv.allow` entry (setting a value there IS the consent to cross this
|
|
33
|
-
* boundary),
|
|
39
|
+
* boundary), F — the TARGET profile's manifest metadata as
|
|
40
|
+
* `CRTR_PROFILE_META_*` (`profileMetadataEnv` above), plus D — the trusted,
|
|
41
|
+
* crtr-constructed `inv.env` overlay and the
|
|
34
42
|
* fork-bomb recursion guard `FRONT_DOOR_ENV=1`. D is layered last so crtr's
|
|
35
43
|
* own constructed env always wins a name collision with a stored profile
|
|
36
44
|
* value. Every broker launch (front-door root, managed child, `--root`,
|
|
@@ -33,6 +33,7 @@ import { statSync } from 'node:fs';
|
|
|
33
33
|
import { delimiter, resolve } from 'node:path';
|
|
34
34
|
import { readMergedLaunchConfig } from '../config.js';
|
|
35
35
|
import { readProfileEnvVars } from '../profiles/env-store.js';
|
|
36
|
+
import { readProfileMetadata } from '../profiles/manifest.js';
|
|
36
37
|
import { binShimDirFor } from './bin-contributions.js';
|
|
37
38
|
import { FRONT_DOOR_ENV } from './front-door-env.js';
|
|
38
39
|
/** A1 — literal env-var names copied verbatim (no value inspection): a login
|
|
@@ -211,6 +212,18 @@ export function buildOperationalEnvBase(opts) {
|
|
|
211
212
|
}
|
|
212
213
|
return out;
|
|
213
214
|
}
|
|
215
|
+
/** Source F — the TARGET profile's manifest `metadata`, each entry surfaced
|
|
216
|
+
* as `CRTR_PROFILE_META_<KEY>` (key uppercased, every non-alphanumeric run
|
|
217
|
+
* → `_`). Identity facts rather than secrets — unlike the env store these
|
|
218
|
+
* are readable back (`profile show`, ProfileDTO). Read straight off the
|
|
219
|
+
* manifest at every launch; never throws (a bad profile id yields `{}`). */
|
|
220
|
+
export function profileMetadataEnv(profileId) {
|
|
221
|
+
const out = {};
|
|
222
|
+
for (const [key, value] of Object.entries(readProfileMetadata(profileId))) {
|
|
223
|
+
out[`CRTR_PROFILE_META_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = value;
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
214
227
|
/** The one authoritative broker child env: sources A–C (the operational
|
|
215
228
|
* base, resolved from the TARGET node's own cwd/profile — `inv.env`'s
|
|
216
229
|
* `CRTR_NODE_CWD`/`CRTR_PROFILE_ID`, never this process's ambient
|
|
@@ -219,7 +232,9 @@ export function buildOperationalEnvBase(opts) {
|
|
|
219
232
|
* own `profile env` store (`readProfileEnvVars`, `core/profiles/env-store.ts`)
|
|
220
233
|
* read directly off disk and injected regardless of the host env or any
|
|
221
234
|
* `spawnEnv.allow` entry (setting a value there IS the consent to cross this
|
|
222
|
-
* boundary),
|
|
235
|
+
* boundary), F — the TARGET profile's manifest metadata as
|
|
236
|
+
* `CRTR_PROFILE_META_*` (`profileMetadataEnv` above), plus D — the trusted,
|
|
237
|
+
* crtr-constructed `inv.env` overlay and the
|
|
223
238
|
* fork-bomb recursion guard `FRONT_DOOR_ENV=1`. D is layered last so crtr's
|
|
224
239
|
* own constructed env always wins a name collision with a stored profile
|
|
225
240
|
* value. Every broker launch (front-door root, managed child, `--root`,
|
|
@@ -231,6 +246,7 @@ export function buildBrokerEnv(inv) {
|
|
|
231
246
|
const operational = buildOperationalEnvBase({ targetCwd, targetProfileId });
|
|
232
247
|
return {
|
|
233
248
|
...operational,
|
|
249
|
+
...profileMetadataEnv(targetProfileId),
|
|
234
250
|
...readProfileEnvVars(targetProfileId),
|
|
235
251
|
...inv.env,
|
|
236
252
|
[ADMITTED_HOST_PATH_ENV]: operational[ADMITTED_HOST_PATH_ENV],
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// so a doc delivered by one never re-delivers at the same or lower rung
|
|
27
27
|
// through another. Each candidate renders at its matched rung — the highest
|
|
28
28
|
// `at` over its matching entries for the event.
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
29
30
|
import { homedir } from 'node:os';
|
|
30
31
|
import { dirname, parse, relative, sep } from 'node:path';
|
|
31
32
|
import { CRTR_DIR_NAME } from '../../types.js';
|
|
@@ -172,6 +173,14 @@ function readFileFrontmatter(absReadFile) {
|
|
|
172
173
|
return {};
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
/** Content-identity dedup key, or null for an empty body (name-only docs
|
|
177
|
+
* must not collide with each other). */
|
|
178
|
+
function docContentKey(doc) {
|
|
179
|
+
const body = doc.body.trim();
|
|
180
|
+
if (body === '')
|
|
181
|
+
return null;
|
|
182
|
+
return `content-sha256:${createHash('sha256').update(body).digest('hex')}`;
|
|
183
|
+
}
|
|
175
184
|
function renderDocEnvelope(doc, rung) {
|
|
176
185
|
if (rung === 'none')
|
|
177
186
|
return null;
|
|
@@ -191,8 +200,13 @@ export function renderCandidateBlocks(subject, candidates, seen) {
|
|
|
191
200
|
const rendered = [];
|
|
192
201
|
for (const { doc, rung } of candidates) {
|
|
193
202
|
const real = realpathOrSelf(doc.path);
|
|
203
|
+
// Byte-identical bodies at different paths (a cloned repo's store) inform
|
|
204
|
+
// once; the content key shares the realpath map and its rung semantics.
|
|
205
|
+
const contentKey = docContentKey(doc);
|
|
194
206
|
if (deliveredAtOrAbove(seen, real, rung))
|
|
195
207
|
continue;
|
|
208
|
+
if (contentKey !== null && deliveredAtOrAbove(seen, contentKey, rung))
|
|
209
|
+
continue;
|
|
196
210
|
try {
|
|
197
211
|
if (subject === null ? doc.gate !== undefined : !gatePasses(doc, subject))
|
|
198
212
|
continue;
|
|
@@ -200,6 +214,8 @@ export function renderCandidateBlocks(subject, candidates, seen) {
|
|
|
200
214
|
if (block === null)
|
|
201
215
|
continue;
|
|
202
216
|
recordDelivery(seen, real, rung);
|
|
217
|
+
if (contentKey !== null)
|
|
218
|
+
recordDelivery(seen, contentKey, rung);
|
|
203
219
|
rendered.push(block);
|
|
204
220
|
}
|
|
205
221
|
catch {
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
export declare function ensureRenderer(): void;
|
|
16
16
|
/** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
|
|
17
17
|
export declare function isRendererReady(): boolean;
|
|
18
|
-
/** Render markdown to terminal lines via the pinned binary; plaintext fallback.
|
|
18
|
+
/** Render markdown to terminal lines via the pinned binary; plaintext fallback.
|
|
19
|
+
* Callers own the returned array — every return is a fresh copy, so a caller
|
|
20
|
+
* that appends to it cannot grow the cached render on the next hit. */
|
|
19
21
|
export declare function renderMarkdown(md: string, width: number): string[];
|
|
20
22
|
export interface RenderedDoc {
|
|
21
23
|
/** Rendered ANSI rows — byte-identical to the plain `doc render` output. */
|
|
@@ -50,7 +52,10 @@ export declare function renderMarkdownWithMap(md: string, width: number): Render
|
|
|
50
52
|
* unchanged.
|
|
51
53
|
*/
|
|
52
54
|
export declare function renderMarkdownBlockAware(md: string, proseWidth: number, paneWidth: number): RenderedDoc;
|
|
53
|
-
/** Block-aware render as plain rows, for surfaces that do not anchor.
|
|
55
|
+
/** Block-aware render as plain rows, for surfaces that do not anchor. The rows
|
|
56
|
+
* are the caller's own array: the block-aware render can pass the mapped
|
|
57
|
+
* cache's line array straight through, and a caller appending to it would
|
|
58
|
+
* otherwise poison every later render of the same document. */
|
|
54
59
|
export declare function renderMarkdownBlockAwareLines(md: string, proseWidth: number, paneWidth: number): string[];
|
|
55
60
|
/** Validate markdown via `termrender doc check`. */
|
|
56
61
|
export declare function checkMarkdown(md: string): {
|
|
@@ -410,12 +410,14 @@ class BoundedLRUCache {
|
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
const _bodyCache = new BoundedLRUCache(20);
|
|
413
|
-
/** Render markdown to terminal lines via the pinned binary; plaintext fallback.
|
|
413
|
+
/** Render markdown to terminal lines via the pinned binary; plaintext fallback.
|
|
414
|
+
* Callers own the returned array — every return is a fresh copy, so a caller
|
|
415
|
+
* that appends to it cannot grow the cached render on the next hit. */
|
|
414
416
|
export function renderMarkdown(md, width) {
|
|
415
417
|
const key = `${md}\0${width}`;
|
|
416
418
|
const cached = _bodyCache.get(key);
|
|
417
419
|
if (cached)
|
|
418
|
-
return cached;
|
|
420
|
+
return [...cached];
|
|
419
421
|
ensureRenderer();
|
|
420
422
|
if (rendererState === 'ready') {
|
|
421
423
|
try {
|
|
@@ -429,7 +431,7 @@ export function renderMarkdown(md, width) {
|
|
|
429
431
|
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
430
432
|
lines.pop();
|
|
431
433
|
_bodyCache.set(key, lines);
|
|
432
|
-
return lines;
|
|
434
|
+
return [...lines];
|
|
433
435
|
}
|
|
434
436
|
catch (err) {
|
|
435
437
|
// `doc render` is best-effort, so a non-timeout failure implicates the
|
|
@@ -441,7 +443,7 @@ export function renderMarkdown(md, width) {
|
|
|
441
443
|
}
|
|
442
444
|
const fallback = wrap(sanitize(md), width);
|
|
443
445
|
_bodyCache.set(key, fallback);
|
|
444
|
-
return fallback;
|
|
446
|
+
return [...fallback];
|
|
445
447
|
}
|
|
446
448
|
/** Block types allowed to use the full pane width instead of the prose cap:
|
|
447
449
|
* a diagram is a picture, not prose, so a readability column limit only
|
|
@@ -657,9 +659,12 @@ export function renderMarkdownBlockAware(md, proseWidth, paneWidth) {
|
|
|
657
659
|
}
|
|
658
660
|
return { lines, rows, spans, blocks: base.blocks };
|
|
659
661
|
}
|
|
660
|
-
/** Block-aware render as plain rows, for surfaces that do not anchor.
|
|
662
|
+
/** Block-aware render as plain rows, for surfaces that do not anchor. The rows
|
|
663
|
+
* are the caller's own array: the block-aware render can pass the mapped
|
|
664
|
+
* cache's line array straight through, and a caller appending to it would
|
|
665
|
+
* otherwise poison every later render of the same document. */
|
|
661
666
|
export function renderMarkdownBlockAwareLines(md, proseWidth, paneWidth) {
|
|
662
|
-
return renderMarkdownBlockAware(md, proseWidth, paneWidth).lines;
|
|
667
|
+
return [...renderMarkdownBlockAware(md, proseWidth, paneWidth).lines];
|
|
663
668
|
}
|
|
664
669
|
/** Validate markdown via `termrender doc check`. */
|
|
665
670
|
export function checkMarkdown(md) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const TERMRENDER_VERSION = "4.12.
|
|
1
|
+
export declare const TERMRENDER_VERSION = "4.12.8";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const TERMRENDER_VERSION = '4.12.
|
|
1
|
+
export const TERMRENDER_VERSION = '4.12.8';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { after, afterEach, beforeEach, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createNode, getNode, listNodes } from '../../../core/canvas/canvas.js';
|
|
7
|
+
import { closeDb } from '../../../core/canvas/db.js';
|
|
8
|
+
import { CrtrError } from '../../../core/errors.js';
|
|
9
|
+
import { createProfile, loadProfileManifest, pauseProfile, setProfileDefaultKind } from '../../../core/profiles/manifest.js';
|
|
10
|
+
import { resetScopeCache } from '../../../core/scope.js';
|
|
11
|
+
import { reviveNode } from '../../../core/runtime/revive.js';
|
|
12
|
+
import { spawnNode } from '../../../core/runtime/nodes.js';
|
|
13
|
+
import { handleCreate, resolveCreateKind } from '../handlers/nodes.js';
|
|
14
|
+
const previousCrtrHome = process.env['CRTR_HOME'];
|
|
15
|
+
const previousHome = process.env.HOME;
|
|
16
|
+
let home = '';
|
|
17
|
+
let cwd = '';
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
closeDb();
|
|
20
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-profile-launch-home-'));
|
|
21
|
+
cwd = mkdtempSync(join(tmpdir(), 'crtr-profile-launch-cwd-'));
|
|
22
|
+
process.env.HOME = home;
|
|
23
|
+
process.env['CRTR_HOME'] = join(home, 'canvas');
|
|
24
|
+
mkdirSync(process.env['CRTR_HOME'], { recursive: true });
|
|
25
|
+
resetScopeCache();
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
closeDb();
|
|
29
|
+
rmSync(home, { recursive: true, force: true });
|
|
30
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
31
|
+
resetScopeCache();
|
|
32
|
+
});
|
|
33
|
+
after(() => {
|
|
34
|
+
if (previousHome === undefined)
|
|
35
|
+
delete process.env.HOME;
|
|
36
|
+
else
|
|
37
|
+
process.env.HOME = previousHome;
|
|
38
|
+
if (previousCrtrHome === undefined)
|
|
39
|
+
delete process.env['CRTR_HOME'];
|
|
40
|
+
else
|
|
41
|
+
process.env['CRTR_HOME'] = previousCrtrHome;
|
|
42
|
+
resetScopeCache();
|
|
43
|
+
});
|
|
44
|
+
test('create rejects a paused profile before a node exists', async () => {
|
|
45
|
+
const profile = createProfile('paused profile', [cwd]);
|
|
46
|
+
pauseProfile(profile.profileId);
|
|
47
|
+
const ctx = {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
path: '/v1/nodes',
|
|
50
|
+
params: {},
|
|
51
|
+
query: new URLSearchParams(),
|
|
52
|
+
body: { root: true, profile: profile.profileId, cwd, prompt: 'must not launch' },
|
|
53
|
+
};
|
|
54
|
+
const beforeRows = listNodes().length;
|
|
55
|
+
await assert.rejects(() => handleCreate(ctx), (error) => {
|
|
56
|
+
assert.ok(error instanceof CrtrError);
|
|
57
|
+
assert.equal(error.code, 'usage');
|
|
58
|
+
assert.match(error.message, /paused profile/);
|
|
59
|
+
assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
assert.equal(listNodes().length, beforeRows);
|
|
63
|
+
});
|
|
64
|
+
test('revive refuses a paused profile before launching a broker', () => {
|
|
65
|
+
const profile = createProfile('paused revive', [cwd]);
|
|
66
|
+
pauseProfile(profile.profileId);
|
|
67
|
+
const nodeId = 'paused-revive-node';
|
|
68
|
+
createNode({
|
|
69
|
+
node_id: nodeId,
|
|
70
|
+
name: nodeId,
|
|
71
|
+
created: new Date().toISOString(),
|
|
72
|
+
cwd,
|
|
73
|
+
kind: 'developer',
|
|
74
|
+
mode: 'base',
|
|
75
|
+
lifecycle: 'resident',
|
|
76
|
+
status: 'active',
|
|
77
|
+
parent: null,
|
|
78
|
+
profile_id: profile.profileId,
|
|
79
|
+
});
|
|
80
|
+
assert.throws(() => reviveNode(nodeId, { resume: true }), (error) => {
|
|
81
|
+
assert.ok(error instanceof CrtrError);
|
|
82
|
+
assert.equal(error.code, 'usage');
|
|
83
|
+
assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
assert.equal(getNode(nodeId)?.status, 'active');
|
|
87
|
+
});
|
|
88
|
+
// The API create gate phrases the refusal; this one is what every OTHER birth
|
|
89
|
+
// (fork, cron spawn, warm-pool mint, `/new` relaunch, recycle) actually hits,
|
|
90
|
+
// because they all reach the canvas through spawnNode and none of them call
|
|
91
|
+
// handleCreate.
|
|
92
|
+
test('the birth gate refuses a paused profile for a daemon-side spawn', () => {
|
|
93
|
+
const profile = createProfile('paused spawn', [cwd]);
|
|
94
|
+
pauseProfile(profile.profileId);
|
|
95
|
+
const beforeRows = listNodes().length;
|
|
96
|
+
assert.throws(() => spawnNode({ kind: 'general', cwd, parent: null, profile_id: profile.profileId }), (error) => {
|
|
97
|
+
assert.ok(error instanceof CrtrError);
|
|
98
|
+
assert.equal(error.code, 'usage');
|
|
99
|
+
assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
|
|
100
|
+
return true;
|
|
101
|
+
});
|
|
102
|
+
assert.equal(listNodes().length, beforeRows);
|
|
103
|
+
});
|
|
104
|
+
test('create kind resolves from the profile default when omitted', () => {
|
|
105
|
+
const profile = createProfile('default kind', [cwd]);
|
|
106
|
+
setProfileDefaultKind(profile.profileId, 'developer');
|
|
107
|
+
const entry = loadProfileManifest(profile.profileId);
|
|
108
|
+
assert.equal(resolveCreateKind(undefined, entry), 'developer');
|
|
109
|
+
assert.equal(resolveCreateKind('general', entry), 'general');
|
|
110
|
+
assert.equal(resolveCreateKind(undefined, null), 'general');
|
|
111
|
+
});
|
|
@@ -169,33 +169,44 @@ function isSlotValidationError(err) {
|
|
|
169
169
|
// ===========================================================================
|
|
170
170
|
// GET /v1/human/inbox — enumerate
|
|
171
171
|
// ===========================================================================
|
|
172
|
+
/** The one page-summary projection: the pending list and the per-node history differ only in which tickets they walk. */
|
|
173
|
+
function pageSummaryToDTO(item, canonicalRoot) {
|
|
174
|
+
const dto = {
|
|
175
|
+
ticket_id: createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex'),
|
|
176
|
+
kind: 'page',
|
|
177
|
+
title: item.title,
|
|
178
|
+
placement: item.placement,
|
|
179
|
+
dialect: item.dialect,
|
|
180
|
+
steps: item.steps,
|
|
181
|
+
emitted_at: item.emittedAt,
|
|
182
|
+
source: item.source,
|
|
183
|
+
slot_kinds: item.slotKinds,
|
|
184
|
+
inbox: item.inbox,
|
|
185
|
+
awaits_response: item.awaitsResponse,
|
|
186
|
+
state: item.state,
|
|
187
|
+
};
|
|
188
|
+
if (item.subtitle !== '')
|
|
189
|
+
dto.subtitle = item.subtitle;
|
|
190
|
+
if (item.answerDigest !== undefined)
|
|
191
|
+
dto.answer_digest = item.answerDigest;
|
|
192
|
+
if (item.recommendedOptions.length > 0) {
|
|
193
|
+
dto.recommended_options = item.recommendedOptions.map((option) => ({ slot_id: option.slotId, option_id: option.optionId, label: option.label }));
|
|
194
|
+
}
|
|
195
|
+
if (item.fastAction !== undefined)
|
|
196
|
+
dto.fast_action = { kind: item.fastAction.kind, responses: item.fastAction.responses };
|
|
197
|
+
return dto;
|
|
198
|
+
}
|
|
172
199
|
function handleList() {
|
|
173
200
|
const allItems = scanInbox();
|
|
174
201
|
const filtered = filterTerminalReviewTickets(allItems);
|
|
202
|
+
const canonicalRoot = realpathSync(ticketsRoot());
|
|
175
203
|
const tickets = filtered.map((item) => {
|
|
176
204
|
if (item.kind === 'page') {
|
|
177
|
-
|
|
178
|
-
const dto = {
|
|
179
|
-
ticket_id: ticketId,
|
|
180
|
-
kind: 'page',
|
|
181
|
-
title: item.title,
|
|
182
|
-
placement: item.placement,
|
|
183
|
-
dialect: item.dialect,
|
|
184
|
-
steps: item.steps,
|
|
185
|
-
emitted_at: item.emittedAt,
|
|
186
|
-
source: item.source,
|
|
187
|
-
slot_kinds: item.slotKinds,
|
|
188
|
-
inbox: item.inbox,
|
|
189
|
-
awaits_response: item.awaitsResponse,
|
|
190
|
-
state: item.state,
|
|
191
|
-
};
|
|
192
|
-
if (item.subtitle !== '')
|
|
193
|
-
dto.subtitle = item.subtitle;
|
|
194
|
-
return dto;
|
|
205
|
+
return pageSummaryToDTO(item, canonicalRoot);
|
|
195
206
|
}
|
|
196
207
|
else if (item.kind === 'review') {
|
|
197
208
|
return {
|
|
198
|
-
ticket_id: createHash('sha256').update(`${
|
|
209
|
+
ticket_id: createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex'),
|
|
199
210
|
kind: 'review',
|
|
200
211
|
title: item.title,
|
|
201
212
|
subtitle: item.subtitle,
|
|
@@ -221,28 +232,7 @@ function handleHistory(ctx) {
|
|
|
221
232
|
}
|
|
222
233
|
const history = scanPageHistory(nodeId);
|
|
223
234
|
const canonicalRoot = realpathSync(ticketsRoot());
|
|
224
|
-
const tickets = history.map((item) =>
|
|
225
|
-
const ticketId = createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex');
|
|
226
|
-
const dto = {
|
|
227
|
-
ticket_id: ticketId,
|
|
228
|
-
kind: 'page',
|
|
229
|
-
title: item.title,
|
|
230
|
-
placement: item.placement,
|
|
231
|
-
dialect: item.dialect,
|
|
232
|
-
steps: item.steps,
|
|
233
|
-
emitted_at: item.emittedAt,
|
|
234
|
-
source: item.source,
|
|
235
|
-
slot_kinds: item.slotKinds,
|
|
236
|
-
inbox: item.inbox,
|
|
237
|
-
awaits_response: item.awaitsResponse,
|
|
238
|
-
state: item.state,
|
|
239
|
-
};
|
|
240
|
-
if (item.subtitle !== '')
|
|
241
|
-
dto.subtitle = item.subtitle;
|
|
242
|
-
if (item.answerDigest !== undefined)
|
|
243
|
-
dto.answer_digest = item.answerDigest;
|
|
244
|
-
return dto;
|
|
245
|
-
});
|
|
235
|
+
const tickets = history.map((item) => pageSummaryToDTO(item, canonicalRoot));
|
|
246
236
|
return { status: 200, body: { tickets } };
|
|
247
237
|
}
|
|
248
238
|
// ===========================================================================
|