@north-light/api 0.3.560 → 0.3.562

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.
Files changed (87) hide show
  1. package/dist/api/client.d.ts +27 -4
  2. package/dist/api/client.js +53 -0
  3. package/dist/api/contract.d.ts +1 -1
  4. package/dist/api/contract.js +1 -1
  5. package/dist/api/dto/bash-jobs.d.ts +5 -1
  6. package/dist/api/dto/broker-ops.d.ts +25 -1
  7. package/dist/api/dto/charges.d.ts +9 -2
  8. package/dist/api/dto/delivery.d.ts +26 -2
  9. package/dist/api/dto/execution-start.d.ts +57 -0
  10. package/dist/api/dto/execution-start.js +2 -0
  11. package/dist/api/dto/modelauth.d.ts +5 -0
  12. package/dist/api/dto/node-records.d.ts +16 -0
  13. package/dist/api/dto/objects.d.ts +1 -1
  14. package/dist/api/dto/reports.d.ts +0 -3
  15. package/dist/api/dto/run-events.d.ts +7 -1
  16. package/dist/api/dto/settings.d.ts +55 -0
  17. package/dist/api/dto/settings.js +4 -0
  18. package/dist/api/index.d.ts +2 -0
  19. package/dist/api/index.js +2 -0
  20. package/dist/api/routes.d.ts +11 -0
  21. package/dist/api/routes.js +12 -0
  22. package/dist/core/canvas/paths.d.ts +0 -1
  23. package/dist/core/canvas/paths.js +0 -3
  24. package/dist/core/canvas/person-state.d.ts +26 -0
  25. package/dist/core/canvas/person-state.js +41 -0
  26. package/dist/core/command-plugins/bundle.d.ts +1 -1
  27. package/dist/core/command-plugins/bundle.js +5 -5
  28. package/dist/core/config.d.ts +16 -15
  29. package/dist/core/config.js +99 -76
  30. package/dist/core/conversation-store/client.d.ts +2 -0
  31. package/dist/core/conversation-store/client.js +111 -0
  32. package/dist/core/conversation-store/index.d.ts +96 -0
  33. package/dist/core/conversation-store/index.js +62 -0
  34. package/dist/core/conversation-store/patch.d.ts +3 -0
  35. package/dist/core/conversation-store/patch.js +192 -0
  36. package/dist/core/conversation-store/repair.d.ts +45 -0
  37. package/dist/core/conversation-store/repair.js +84 -0
  38. package/dist/core/env-name.d.ts +1 -0
  39. package/dist/core/env-name.js +16 -0
  40. package/dist/core/events/emit.js +7 -5
  41. package/dist/core/git.d.ts +28 -0
  42. package/dist/core/git.js +103 -0
  43. package/dist/core/installed-plugins.js +12 -7
  44. package/dist/core/managed-providers.d.ts +72 -0
  45. package/dist/core/managed-providers.js +61 -0
  46. package/dist/core/plugin-env.js +1 -2
  47. package/dist/core/profiles/manifest.d.ts +14 -18
  48. package/dist/core/profiles/manifest.js +79 -98
  49. package/dist/core/runtime/broker/attachments-fetch.d.ts +5 -0
  50. package/dist/core/runtime/broker/attachments-fetch.js +20 -0
  51. package/dist/core/runtime/broker/daemon-ops.d.ts +10 -1
  52. package/dist/core/runtime/broker/daemon-ops.js +41 -3
  53. package/dist/core/runtime/broker/node-token.d.ts +3 -0
  54. package/dist/core/runtime/broker/node-token.js +16 -0
  55. package/dist/core/runtime/broker/signal-stream.js +2 -2
  56. package/dist/core/scoped-state/materialized-plugins.d.ts +18 -0
  57. package/dist/core/scoped-state/materialized-plugins.js +103 -0
  58. package/dist/core/scoped-state/person-store.d.ts +72 -0
  59. package/dist/core/scoped-state/person-store.js +112 -0
  60. package/dist/core/user-settings.js +1 -1
  61. package/dist/daemon/broker-signals/bus.d.ts +48 -11
  62. package/dist/daemon/broker-signals/bus.js +116 -46
  63. package/dist/shared/inbox-entry-body.js +2 -2
  64. package/dist/types.d.ts +12 -0
  65. package/package.json +1 -1
  66. package/dist/core/canvas/legacy-person.d.ts +0 -4
  67. package/dist/core/canvas/legacy-person.js +0 -4
  68. package/dist/core/locked-file.d.ts +0 -27
  69. package/dist/core/locked-file.js +0 -121
  70. package/dist/core/profiles/env-store.d.ts +0 -22
  71. package/dist/core/profiles/env-store.js +0 -166
  72. package/dist/core/scoped-state/db.d.ts +0 -17
  73. package/dist/core/scoped-state/db.js +0 -261
  74. package/dist/core/scoped-state/migrate.d.ts +0 -8
  75. package/dist/core/scoped-state/migrate.js +0 -187
  76. package/dist/core/scoped-state/profiles.d.ts +0 -27
  77. package/dist/core/scoped-state/profiles.js +0 -94
  78. package/dist/core/scoped-state/providers.d.ts +0 -24
  79. package/dist/core/scoped-state/providers.js +0 -19
  80. package/dist/core/scoped-state/schema.d.ts +0 -10
  81. package/dist/core/scoped-state/schema.js +0 -47
  82. package/dist/core/scoped-state/settings.d.ts +0 -28
  83. package/dist/core/scoped-state/settings.js +0 -104
  84. package/dist/core/sqlite-statements.d.ts +0 -4
  85. package/dist/core/sqlite-statements.js +0 -17
  86. package/dist/core/subscription-state.d.ts +0 -129
  87. package/dist/core/subscription-state.js +0 -299
@@ -1,15 +1,13 @@
1
1
  // Profile definitions live in scoped state; memory and environment have separate roots.
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync, realpathSync, rmSync, statSync } from 'node:fs';
4
- import { join, resolve as resolvePath } from 'node:path';
4
+ import { resolve as resolvePath } from 'node:path';
5
5
  import { SCHEMA_VERSION } from '../../types.js';
6
6
  import { profileSpace } from '../layout.js';
7
7
  import { PROFILE_PROJECT_MEMORY_VALUES } from '../../api/dto/profiles.js';
8
- import { userScopeRoot } from '../scope.js';
9
8
  import { ensureDir, nowIso } from '../fs-utils.js';
10
- import { deleteStoredProfile, getStoredProfile, insertStoredProfile, listStoredProfiles, pausedProfileIds, profilePaused, updateStoredProfile } from '../scoped-state/profiles.js';
9
+ import { cachedPausedProfileIds, cachedProfilePaused, getCachedProfile as getStoredProfile, listCachedProfiles, writeSettings } from '../scoped-state/person-store.js';
11
10
  import { usage, notFound, ambiguous, general } from '../errors.js';
12
- import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
13
11
  import { fuzzyMatchProfile } from './fuzzy-match.js';
14
12
  // A generated id and its stored app determine the memory and environment paths.
15
13
  /** `<slug>-<shortid>`: slug is lower-case, non-alphanumeric collapsed to `-`,
@@ -33,7 +31,7 @@ function assertSafeProfileId(profileId) {
33
31
  }
34
32
  export function profileDir(profileId) {
35
33
  assertSafeProfileId(profileId);
36
- const stored = getStoredProfile(userScopeRoot(), profileId);
34
+ const stored = getStoredProfile(profileId);
37
35
  if (stored === null)
38
36
  throw notFound(`profile not found: ${profileId}`, { received: profileId });
39
37
  return profileSpace(profileApp(stored.manifest), profileId).dir;
@@ -61,7 +59,7 @@ export function assertDeliveryLimits(value) {
61
59
  throw usage(`invalid delivery limit for ${handle}: ${JSON.stringify(level)}`, { received: String(level), field: 'delivery_limits', next: `Set ${handle} to one of: ${levels}.` });
62
60
  }
63
61
  }
64
- function enumerateProfiles() { return { entries: listStoredProfiles(userScopeRoot()), unreadableIds: [] }; }
62
+ function enumerateProfiles() { return { entries: listCachedProfiles(), unreadableIds: [] }; }
65
63
  export function listProfiles() { return enumerateProfiles().entries; }
66
64
  export function lookupProfile(profileIdOrName) { const entries = listProfiles(); const exact = findExactProfile(entries, profileIdOrName, entryCandidate); return exact ? { kind: 'resolved', entry: exact } : { kind: 'absent' }; }
67
65
  // Three resolvers, in descending tolerance. `resolveProfileOperand` is what a
@@ -75,7 +73,7 @@ export function lookupProfile(profileIdOrName) { const entries = listProfiles();
75
73
  * Used at persistence gates that already hold a resolved durable identity. */
76
74
  export function loadExactProfileManifest(profileId) {
77
75
  assertSafeProfileId(profileId);
78
- const stored = getStoredProfile(userScopeRoot(), profileId);
76
+ const stored = getStoredProfile(profileId);
79
77
  if (stored !== null)
80
78
  return stored;
81
79
  throw notFound(`profile not found: ${profileId}`, { received: profileId, next: 'Run `crtr profile list` to see available profiles.' });
@@ -159,19 +157,6 @@ export function matchProfileOperand(all, operand, key) {
159
157
  }
160
158
  throw profileNotFound(all, operand, key);
161
159
  }
162
- function locksDir() { return join(userScopeRoot(), 'profile-locks'); }
163
- /** Hold the per-profile manifest lock for the duration of `fn`. ALL manifest
164
- * mutations (create/rename/add-project/remove-project/delete/last-used)
165
- * below run inside this. */
166
- export function withProfileManifestLock(profileId, fn) {
167
- assertSafeProfileId(profileId);
168
- ensureDir(locksDir());
169
- return withExclusiveDirectoryLock(join(locksDir(), `${profileId}.lock`), fn, {
170
- timeoutError: () => general(`timed out waiting for the profile lock: ${profileId}`, {
171
- next: 'Retry — another `crtr profile` command may be running concurrently on this profile.',
172
- }),
173
- });
174
- }
175
160
  // Project dirs — resolved to absolute real paths, must exist + be a directory
176
161
  // on add/create, deduped by real path, stored in manifest order.
177
162
  function resolveExistingProjectDir(dir) {
@@ -212,32 +197,30 @@ function generateProfileId(name) {
212
197
  for (let attempt = 0; attempt < 20; attempt++) {
213
198
  const shortId = randomUUID().replace(/-/g, '').slice(0, 8);
214
199
  const candidate = `${slug}-${shortId}`;
215
- if (getStoredProfile(userScopeRoot(), candidate) === null)
200
+ if (getStoredProfile(candidate) === null)
216
201
  return candidate;
217
202
  }
218
203
  throw general(`failed to generate a unique profile id for "${name}" after 20 attempts`);
219
204
  }
220
205
  // Mutations — all under withProfileManifestLock, all atomic writes.
221
- export function ensureRootProfile() {
222
- return withProfileManifestLock(ROOT_PROFILE_ID, () => {
223
- const existing = getStoredProfile(userScopeRoot(), ROOT_PROFILE_ID);
224
- if (existing !== null) {
225
- scaffoldProfile(ROOT_PROFILE_ID, existing.manifest);
226
- return existing;
227
- }
228
- const next = {
229
- schema_version: SCHEMA_VERSION,
230
- name: ROOT_PROFILE_NAME,
231
- projects: [],
232
- grantee: 'app:terminal',
233
- paused_at: null,
234
- created_at: nowIso(),
235
- last_used_at: nowIso(),
236
- };
237
- scaffoldProfile(ROOT_PROFILE_ID, next);
238
- insertStoredProfile(userScopeRoot(), { profileId: ROOT_PROFILE_ID, manifest: next });
239
- return { profileId: ROOT_PROFILE_ID, manifest: next };
240
- });
206
+ export async function ensureRootProfile() {
207
+ const existing = getStoredProfile(ROOT_PROFILE_ID);
208
+ if (existing !== null) {
209
+ scaffoldProfile(ROOT_PROFILE_ID, existing.manifest);
210
+ return existing;
211
+ }
212
+ const next = {
213
+ schema_version: SCHEMA_VERSION,
214
+ name: ROOT_PROFILE_NAME,
215
+ projects: [],
216
+ grantee: 'app:terminal',
217
+ paused_at: null,
218
+ created_at: nowIso(),
219
+ last_used_at: nowIso(),
220
+ };
221
+ scaffoldProfile(ROOT_PROFILE_ID, next);
222
+ await writeSettings((snapshot) => snapshot.profiles.some((p) => p.profileId === ROOT_PROFILE_ID) ? [] : [{ op: 'profile.insert', profile: { profileId: ROOT_PROFILE_ID, manifest: next } }]);
223
+ return getStoredProfile(ROOT_PROFILE_ID);
241
224
  }
242
225
  /** Key shape every stored metadata entry must satisfy — kept env-mappable
243
226
  * (`CRTR_PROFILE_META_<KEY>`, see `core/runtime/spawn-env.ts`) by
@@ -316,7 +299,7 @@ export function sanitizeProfileMetadata(stored) {
316
299
  }
317
300
  return out;
318
301
  }
319
- export function createProfile(name, projects = [], opts = {}) {
302
+ export async function createProfile(name, projects = [], opts = {}) {
320
303
  const trimmed = name.trim();
321
304
  if (trimmed === '')
322
305
  throw usage('profile name must not be empty');
@@ -325,24 +308,22 @@ export function createProfile(name, projects = [], opts = {}) {
325
308
  assertProfileProjects(projects);
326
309
  const resolvedProjects = dedupeProjects(projects.map((p) => ({ path: resolveExistingProjectDir(p.path) })));
327
310
  const profileId = generateProfileId(trimmed);
328
- return withProfileManifestLock(profileId, () => {
329
- const manifest = {
330
- schema_version: SCHEMA_VERSION,
331
- name: trimmed,
332
- projects: resolvedProjects,
333
- paused_at: null,
334
- ...(opts.grantee === undefined ? {} : { grantee: opts.grantee }),
335
- ...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
336
- ...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
337
- created_at: nowIso(),
338
- last_used_at: null,
339
- };
340
- scaffoldProfile(profileId, manifest);
341
- insertStoredProfile(userScopeRoot(), { profileId, manifest });
342
- return { profileId, manifest };
343
- });
344
- }
345
- export function ensureAppProfile(grantee) {
311
+ const manifest = {
312
+ schema_version: SCHEMA_VERSION,
313
+ name: trimmed,
314
+ projects: resolvedProjects,
315
+ paused_at: null,
316
+ ...(opts.grantee === undefined ? {} : { grantee: opts.grantee }),
317
+ ...(opts.defaultKind !== undefined ? { default_kind: opts.defaultKind } : {}),
318
+ ...(opts.metadata !== undefined && Object.keys(opts.metadata).length > 0 ? { metadata: { ...opts.metadata } } : {}),
319
+ created_at: nowIso(),
320
+ last_used_at: null,
321
+ };
322
+ scaffoldProfile(profileId, manifest);
323
+ await writeSettings(() => [{ op: 'profile.insert', profile: { profileId, manifest } }]);
324
+ return { profileId, manifest };
325
+ }
326
+ export async function ensureAppProfile(grantee) {
346
327
  if (!grantee.startsWith('app:') || grantee.length <= 4)
347
328
  throw usage('an app grantee is required');
348
329
  const name = `app-${grantee.slice(4)}`;
@@ -351,24 +332,27 @@ export function ensureAppProfile(grantee) {
351
332
  throw new Error(`duplicate default profile for ${grantee}`);
352
333
  if (existing[0])
353
334
  return existing[0];
354
- return createProfile(name, [], { grantee });
335
+ return await createProfile(name, [], { grantee });
355
336
  }
356
337
  function requireExistingProfile(profileId) {
357
338
  assertSafeProfileId(profileId);
358
- if (getStoredProfile(userScopeRoot(), profileId) === null)
339
+ if (getStoredProfile(profileId) === null)
359
340
  throw notFound(`profile not found: ${profileId}`, { received: profileId, next: 'Run `crtr profile list` to see available profiles.' });
360
341
  }
361
- function mutateManifest(profileId, mutate) {
342
+ async function mutateManifest(profileId, mutate) {
362
343
  requireExistingProfile(profileId);
363
- return withProfileManifestLock(profileId, () => {
364
- const result = updateStoredProfile(userScopeRoot(), profileId, mutate);
365
- if (result === null)
344
+ let result;
345
+ await writeSettings((snapshot) => {
346
+ const current = snapshot.profiles.find((p) => p.profileId === profileId);
347
+ if (current === undefined)
366
348
  throw notFound(`profile not found: ${profileId}`, { received: profileId });
367
- return result;
349
+ result = { profileId, manifest: mutate(structuredClone(current.manifest)) };
350
+ return [{ op: 'profile.update', profile: result }];
368
351
  });
352
+ return result;
369
353
  }
370
- export function updateProfileLastUsed(profileId) {
371
- return mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
354
+ export async function updateProfileLastUsed(profileId) {
355
+ return await mutateManifest(profileId, (m) => ({ ...m, last_used_at: nowIso() }));
372
356
  }
373
357
  /** Resolve an existing profile for a launch gate without making stale durable
374
358
  * profile ids a new failure mode on a hot path. */
@@ -390,17 +374,13 @@ function loadProfileManifestOrNull(profileId) {
390
374
  export function isProfilePaused(profileId) {
391
375
  if (profileId === null || profileId === undefined || profileId === '')
392
376
  return false;
393
- try {
394
- assertSafeProfileId(profileId);
395
- return profilePaused(userScopeRoot(), profileId);
396
- }
397
- catch {
377
+ if (!ID_SHAPE.test(profileId))
398
378
  return false;
399
- }
379
+ return cachedProfilePaused(profileId);
400
380
  }
401
381
  /** One set-based pause read for canvas-wide projections. */
402
382
  export function pausedCanvasProfiles() {
403
- return pausedProfileIds(userScopeRoot());
383
+ return cachedPausedProfileIds();
404
384
  }
405
385
  /** Refuse a launch under a paused profile, naming the resume command. Shared by
406
386
  * every gate that already holds a loaded manifest. */
@@ -419,25 +399,25 @@ export function assertProfileActive(profileId) {
419
399
  assertProfileEntryActive(entry);
420
400
  return entry;
421
401
  }
422
- export function pauseProfile(profileId) {
423
- return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
402
+ export async function pauseProfile(profileId) {
403
+ return await mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
424
404
  }
425
- export function resumeProfile(profileId) {
405
+ export async function resumeProfile(profileId) {
426
406
  let pausedAt = null;
427
- const entry = mutateManifest(profileId, (m) => {
407
+ const entry = await mutateManifest(profileId, (m) => {
428
408
  pausedAt = m.paused_at;
429
409
  return { ...m, paused_at: null };
430
410
  });
431
411
  return { ...entry, pausedAt };
432
412
  }
433
- export function setProfileDefaultKind(profileId, defaultKind) {
434
- return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
413
+ export async function setProfileDefaultKind(profileId, defaultKind) {
414
+ return await mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
435
415
  }
436
416
  /** Merge `set` entries over the stored map and drop `unset` keys; the
437
417
  * `metadata` field is omitted entirely when the result is empty. */
438
- export function updateProfileMetadata(profileId, set, unset = []) {
418
+ export async function updateProfileMetadata(profileId, set, unset = []) {
439
419
  assertProfileMetadata(set);
440
- return mutateManifest(profileId, (m) => {
420
+ return await mutateManifest(profileId, (m) => {
441
421
  const next = { ...sanitizeProfileMetadata(m.metadata), ...set };
442
422
  for (const key of unset)
443
423
  delete next[key];
@@ -467,32 +447,32 @@ export function readProfileMetadata(profileId) {
467
447
  /** Change the manifest `name` only. Callers that own the canvas (the daemon's
468
448
  * `PATCH /v1/profiles/:name`) must also rename the profile's canvas handle —
469
449
  * use `renameProfileAndHandle` there; the CLI and panels go through that route. */
470
- export function renameProfile(profileId, name) {
450
+ export async function renameProfile(profileId, name) {
471
451
  const trimmed = name.trim();
472
452
  if (trimmed === '')
473
453
  throw usage('profile name must not be empty');
474
- const result = mutateManifest(profileId, (m) => ({ ...m, name: trimmed }));
454
+ const result = await mutateManifest(profileId, (m) => ({ ...m, name: trimmed }));
475
455
  return result;
476
456
  }
477
457
  /** A new path appends; a path already listed keeps its position. */
478
- export function addProfileProject(profileId, dir) {
458
+ export async function addProfileProject(profileId, dir) {
479
459
  assertProfileProjects([{ path: dir }]);
480
460
  const resolved = resolveExistingProjectDir(dir);
481
- const result = mutateManifest(profileId, (m) => ({ ...m, projects: dedupeProjects([...m.projects, { path: resolved }]) }));
461
+ const result = await mutateManifest(profileId, (m) => ({ ...m, projects: dedupeProjects([...m.projects, { path: resolved }]) }));
482
462
  return result;
483
463
  }
484
464
  /** Set one owner's automatic delivery limit. `content` is the uncapped level,
485
465
  * so it removes the owner's entry instead of storing it. */
486
- export function setProfileDeliveryLimit(profileId, owner, level) {
466
+ export async function setProfileDeliveryLimit(profileId, owner, level) {
487
467
  assertDeliveryLimits({ [owner]: level });
488
- return mutateManifest(profileId, (m) => {
468
+ return await mutateManifest(profileId, (m) => {
489
469
  const { [owner]: _previous, ...rest } = m.delivery_limits ?? {};
490
470
  const limits = level === 'content' ? rest : { ...rest, [owner]: level };
491
471
  const { delivery_limits: _old, ...base } = m;
492
472
  return Object.keys(limits).length === 0 ? base : { ...base, delivery_limits: limits };
493
473
  });
494
474
  }
495
- export function removeProfileProject(profileId, dir) {
475
+ export async function removeProfileProject(profileId, dir) {
496
476
  // Lenient resolution: a project dir may have been deleted since it was
497
477
  // added, so removal must not require it to still exist on disk. Compare
498
478
  // against both its real path (if still resolvable) and its plain resolved
@@ -505,7 +485,7 @@ export function removeProfileProject(profileId, dir) {
505
485
  catch {
506
486
  /* dir no longer exists — fall back to comparing by its resolved path */
507
487
  }
508
- const result = mutateManifest(profileId, (m) => {
488
+ const result = await mutateManifest(profileId, (m) => {
509
489
  const filtered = m.projects.filter((p) => p.path !== real && p.path !== abs);
510
490
  if (filtered.length === m.projects.length) {
511
491
  throw notFound(`project not found on profile: ${dir}`, {
@@ -517,13 +497,14 @@ export function removeProfileProject(profileId, dir) {
517
497
  });
518
498
  return result;
519
499
  }
520
- export function deleteProfile(profileId) {
500
+ export async function deleteProfile(profileId) {
521
501
  requireExistingProfile(profileId);
522
- withProfileManifestLock(profileId, () => {
523
- const space = profileSpace(profileApp(loadExactProfileManifest(profileId).manifest), profileId);
524
- if (!deleteStoredProfile(userScopeRoot(), profileId))
502
+ const space = profileSpace(profileApp(loadExactProfileManifest(profileId).manifest), profileId);
503
+ await writeSettings((snapshot) => {
504
+ if (!snapshot.profiles.some((p) => p.profileId === profileId))
525
505
  throw notFound(`profile not found: ${profileId}`, { received: profileId });
526
- rmSync(space.dir, { recursive: true, force: true });
527
- rmSync(space.env, { force: true });
506
+ return [{ op: 'profile.delete', profileId }];
528
507
  });
508
+ rmSync(space.dir, { recursive: true, force: true });
509
+ rmSync(space.env, { force: true });
529
510
  }
@@ -0,0 +1,5 @@
1
+ /** Download every attachment of this node's run that is not already on the broker's disk (D98). The control plane puts
2
+ * each fetched upload in the blob store and records it on the run; the files are written where the run's prompt names
3
+ * them. Run before the broker hands the agent a prompt or mail. A failed download throws: the agent must not be told
4
+ * of a path that holds nothing. */
5
+ export declare function fetchRunAttachments(nodeId: string): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { daemonClient } from './daemon-ops.js';
4
+ /** Download every attachment of this node's run that is not already on the broker's disk (D98). The control plane puts
5
+ * each fetched upload in the blob store and records it on the run; the files are written where the run's prompt names
6
+ * them. Run before the broker hands the agent a prompt or mail. A failed download throws: the agent must not be told
7
+ * of a path that holds nothing. */
8
+ export async function fetchRunAttachments(nodeId) {
9
+ const client = daemonClient();
10
+ const { attachments } = await client.listNodeAttachments(nodeId);
11
+ for (const file of attachments) {
12
+ if (existsSync(file.path))
13
+ continue;
14
+ const bytes = await client.readNodeAttachment(nodeId, file.sha256);
15
+ mkdirSync(dirname(file.path), { recursive: true });
16
+ const partial = `${file.path}.partial`;
17
+ writeFileSync(partial, bytes);
18
+ renameSync(partial, file.path);
19
+ }
20
+ }
@@ -1,11 +1,16 @@
1
+ import { CrtrClient } from '../../../api/client.js';
1
2
  import { type BrokerSignalCachedNode } from '../../../daemon/broker-signals/bus.js';
2
3
  import type { BrokerExtensionStateDTO, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerParkActivityResultDTO, BrokerParkCompleteRequest, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest, BrokerTelemetryRequest } from '../../../api/dto/broker-ops.js';
3
4
  import type { AcknowledgeMailResultDTO, ClaimMailResultDTO, MailReceiptDTO } from '../../../api/dto/mail.js';
4
5
  import type { BrokerFaultInputDTO, BrokerFaultRequest, BrokerFaultResultDTO, BrokerProviderRetryRequest, BrokerProviderRetryResultDTO, BrokerTurnRequest, BrokerTurnResultDTO, RecoveryStateDTO } from '../../../api/dto/recovery.js';
5
6
  import type { RelaunchRootResultDTO } from '../../../api/dto/lifecycle.js';
6
7
  import type { PushReportRequest } from '../../../api/dto/reports.js';
7
- import type { BackgroundedJobDTO, BackgroundedJobRequest, DeliveryRequest, DeliveryResponse } from '../../../api/dto/delivery.js';
8
+ import type { BackgroundedJobDTO, BackgroundedJobRequest, JobReportRequest, DeliveryRequest, DeliveryResponse } from '../../../api/dto/delivery.js';
8
9
  import type { NodeLogAppendResultDTO, NodeMessageBodyDTO, NodePushedFinalResultDTO, NodeTelemetryRecordRequest } from '../../../api/dto/node-records.js';
10
+ /** Where this broker reaches `/v1`: `CRTR_API_URL` when the launcher set one (an http(s) URL in a
11
+ * sandbox), else the local API socket. */
12
+ export declare function brokerApiUrl(): string | undefined;
13
+ export declare function daemonClient(): CrtrClient;
9
14
  export declare function appendBrokerLog(nodeId: string, record: string): Promise<NodeLogAppendResultDTO>;
10
15
  export declare function putBrokerTelemetry(nodeId: string, telemetry: Omit<NodeTelemetryRecordRequest, 'expected_execution_id'>): Promise<void>;
11
16
  export declare function putBrokerRecap(nodeId: string, briefing: string): Promise<void>;
@@ -22,15 +27,19 @@ export declare function startBrokerBashJob(nodeId: string, request: {
22
27
  }>;
23
28
  /** Record a command the valve just backgrounded as this node's job object. */
24
29
  export declare function recordBackgroundedJob(nodeId: string, jobId: string, request: BackgroundedJobRequest): Promise<BackgroundedJobDTO>;
30
+ /** Report on a job this broker runs: process identity, activity, output, exit. */
31
+ export declare function reportBrokerJob(nodeId: string, jobId: string, request: Omit<JobReportRequest, 'expected_execution_id'>): Promise<void>;
25
32
  export declare function stopBrokerBashJob(nodeId: string, jobId: string): Promise<void>;
26
33
  export declare function brokerExecutionId(): string;
27
34
  export declare function bindBrokerSession(nodeId: string, request: Omit<BrokerSessionBoundRequest, 'executionId'>): Promise<BrokerSessionBoundResultDTO>;
28
35
  type BrokerInput<T> = T extends unknown ? Omit<T, 'expected_execution_id'> : never;
29
36
  export declare function settleBroker(nodeId: string, request: BrokerInput<BrokerSettleRequest>): Promise<BrokerSettleDirective>;
37
+ export declare function postBrokerIdle(nodeId: string): Promise<void>;
30
38
  export declare function completeBrokerPark(nodeId: string, request: BrokerInput<BrokerParkCompleteRequest>): Promise<BrokerSettleDirective>;
31
39
  export declare function recordBrokerParkActivity(nodeId: string): Promise<BrokerParkActivityResultDTO>;
32
40
  export declare function recordBrokerTelemetry(nodeId: string, request: Omit<BrokerTelemetryRequest, 'expected_execution_id'>): Promise<void>;
33
41
  export declare function claimBrokerMail(nodeId: string, channel: 'wake' | 'passive' | 'deferred_run', reportBodies: Map<string, Promise<string>>): Promise<ClaimMailResultDTO>;
42
+ /** Mail is settled only after the entries the turn wrote are durable: a failed flush rejects and nothing is acknowledged. */
34
43
  export declare function acknowledgeBrokerMail(nodeId: string, deliveries: MailReceiptDTO[]): Promise<AcknowledgeMailResultDTO>;
35
44
  export declare function recordBrokerTurn(nodeId: string, request: Omit<BrokerTurnRequest, 'expected_execution_id'>): Promise<BrokerTurnResultDTO>;
36
45
  export declare function mutateBrokerProviderRetry(nodeId: string, request: Omit<BrokerProviderRetryRequest, 'expected_execution_id'>): Promise<BrokerProviderRetryResultDTO>;
@@ -1,14 +1,33 @@
1
1
  // Broker-to-daemon operation client. A broker is launched by crtrd, so a cold
2
2
  // daemon socket must fail loud — it must never use the CLI factory that can
3
3
  // autostart another daemon.
4
+ import { CrtrClient } from '../../../api/client.js';
5
+ import { currentNodeToken } from './node-token.js';
4
6
  import { localClient } from '../../../api/node-transport.js';
5
7
  import { ApiError } from '../../../api/errors.js';
6
8
  import { envExecutionId } from '../../../shared/env.js';
7
9
  import { brokerSignalClient } from './signal-stream.js';
8
10
  import { cachedBrokerNodeFields } from '../../../daemon/broker-signals/bus.js';
11
+ import { fetchRunAttachments } from './attachments-fetch.js';
9
12
  import { reportRefNodeId } from '../../../shared/inbox-entry-body.js';
10
- function daemonClient() {
11
- return localClient({ autostart: false });
13
+ /** Where this broker reaches `/v1`: `CRTR_API_URL` when the launcher set one (an http(s) URL in a
14
+ * sandbox), else the local API socket. */
15
+ export function brokerApiUrl() {
16
+ const url = process.env['CRTR_API_URL'];
17
+ if (url === undefined || url === '')
18
+ return undefined;
19
+ if (!/^https?:\/\//.test(url))
20
+ throw new Error(`CRTR_API_URL must be an http(s) URL: ${url}`);
21
+ return url;
22
+ }
23
+ export function daemonClient() {
24
+ const url = brokerApiUrl();
25
+ if (url === undefined)
26
+ return localClient({ autostart: false });
27
+ const token = currentNodeToken();
28
+ if (token === undefined)
29
+ throw new Error('broker has no node token for CRTR_API_URL');
30
+ return new CrtrClient({ baseUrl: url, headers: { authorization: `Bearer ${token}` } });
12
31
  }
13
32
  export function appendBrokerLog(nodeId, record) {
14
33
  return daemonClient().appendNodeLog(nodeId, { record, expected_execution_id: brokerExecutionId() });
@@ -38,6 +57,10 @@ export function startBrokerBashJob(nodeId, request) {
38
57
  export function recordBackgroundedJob(nodeId, jobId, request) {
39
58
  return daemonClient().recordBackgroundedJob(nodeId, jobId, request);
40
59
  }
60
+ /** Report on a job this broker runs: process identity, activity, output, exit. */
61
+ export function reportBrokerJob(nodeId, jobId, request) {
62
+ return daemonClient().reportBashJob(nodeId, jobId, { ...request, expected_execution_id: brokerExecutionId() });
63
+ }
41
64
  export function stopBrokerBashJob(nodeId, jobId) {
42
65
  return daemonClient().request('DELETE', `/v1/nodes/${encodeURIComponent(nodeId)}/jobs/${encodeURIComponent(jobId)}?foreground=1`, {
43
66
  expected_execution_id: brokerExecutionId(),
@@ -55,6 +78,9 @@ export function bindBrokerSession(nodeId, request) {
55
78
  export function settleBroker(nodeId, request) {
56
79
  return daemonClient().settleBroker(nodeId, { ...request, expected_execution_id: brokerExecutionId() });
57
80
  }
81
+ export function postBrokerIdle(nodeId) {
82
+ return daemonClient().postBrokerIdle(nodeId, { expected_execution_id: brokerExecutionId() });
83
+ }
58
84
  export function completeBrokerPark(nodeId, request) {
59
85
  return daemonClient().completeBrokerPark(nodeId, { ...request, expected_execution_id: brokerExecutionId() });
60
86
  }
@@ -64,12 +90,21 @@ export function recordBrokerParkActivity(nodeId) {
64
90
  export function recordBrokerTelemetry(nodeId, request) {
65
91
  return daemonClient().recordBrokerTelemetry(nodeId, { ...request, expected_execution_id: brokerExecutionId() });
66
92
  }
93
+ /** Entries whose run attachments this broker has already downloaded. */
94
+ const attachmentsChecked = new Set();
67
95
  export async function claimBrokerMail(nodeId, channel, reportBodies) {
68
96
  const client = daemonClient();
69
97
  const executionId = brokerExecutionId();
70
98
  const result = await client.claimNodeMail(nodeId, { execution_id: executionId, channel });
71
99
  if (result.action === 'hold')
72
100
  return result;
101
+ // Mail the agent has not seen may name a file of this run's attachments (D98): have it on disk before the mail is handed over.
102
+ const fresh = result.deliveries.filter((delivery) => !attachmentsChecked.has(delivery.entry.entry_id));
103
+ if (fresh.length > 0) {
104
+ await fetchRunAttachments(nodeId);
105
+ for (const delivery of fresh)
106
+ attachmentsChecked.add(delivery.entry.entry_id);
107
+ }
73
108
  const deliveries = await Promise.all(result.deliveries.map(async (delivery) => {
74
109
  const ref = delivery.entry.ref;
75
110
  if (ref === undefined || reportRefNodeId(ref) === undefined)
@@ -103,7 +138,10 @@ export async function claimBrokerMail(nodeId, channel, reportBodies) {
103
138
  }
104
139
  return { ...result, deliveries };
105
140
  }
106
- export function acknowledgeBrokerMail(nodeId, deliveries) {
141
+ /** Mail is settled only after the entries the turn wrote are durable: a failed flush rejects and nothing is acknowledged. */
142
+ export async function acknowledgeBrokerMail(nodeId, deliveries) {
143
+ // Loaded on use: the store's index pulls in the pi engine, which a thin CLI leaf must not load.
144
+ await (await import('../../conversation-store/index.js')).flushConversation();
107
145
  return daemonClient().acknowledgeNodeMail(nodeId, { execution_id: brokerExecutionId(), deliveries });
108
146
  }
109
147
  export function recordBrokerTurn(nodeId, request) {
@@ -0,0 +1,3 @@
1
+ /** fd 3 is a one-shot pipe from the daemon, not a file or an environment value. */
2
+ export declare function receiveNodeToken(): Promise<string>;
3
+ export declare function currentNodeToken(): string | undefined;
@@ -0,0 +1,16 @@
1
+ import { createReadStream } from 'node:fs';
2
+ let token = null;
3
+ /** fd 3 is a one-shot pipe from the daemon, not a file or an environment value. */
4
+ export async function receiveNodeToken() {
5
+ if (token !== null)
6
+ throw new Error('node token was already received');
7
+ const chunks = [];
8
+ for await (const chunk of createReadStream('', { fd: 3, autoClose: true }))
9
+ chunks.push(Buffer.from(chunk));
10
+ const received = Buffer.concat(chunks).toString('utf8');
11
+ if (!/^[A-Za-z0-9_-]{43}$/.test(received))
12
+ throw new Error('broker received an invalid node token');
13
+ token = received;
14
+ return received;
15
+ }
16
+ export function currentNodeToken() { return token ?? undefined; }
@@ -1,6 +1,6 @@
1
1
  // Process-wide broker signal connection. Pi's Jiti loader evaluates extensions in
2
2
  // another module graph, so a module-local singleton would open duplicate streams.
3
- import { localClient } from '../../../api/node-transport.js';
3
+ import { daemonClient } from './daemon-ops.js';
4
4
  import { ApiError } from '../../../api/errors.js';
5
5
  const KEY = Symbol.for('@crouton-kit/crtr:broker-signal-client');
6
6
  export class BrokerSignalClient {
@@ -84,7 +84,7 @@ export class BrokerSignalClient {
84
84
  this.controller = controller;
85
85
  void (async () => {
86
86
  try {
87
- for await (const line of localClient({ autostart: false }).getBrokerSignals(this.nodeId, this.executionId, { signal: controller.signal, maxRetries: 0 })) {
87
+ for await (const line of daemonClient().getBrokerSignals(this.nodeId, this.executionId, { signal: controller.signal, maxRetries: 0 })) {
88
88
  if (!this.running || this.controller !== controller)
89
89
  return;
90
90
  this.armHeartbeat(controller);
@@ -0,0 +1,18 @@
1
+ /** One enabled plugin row of the person's user scope, with its marketplace's url and ref. */
2
+ export interface PluginInstall {
3
+ name: string;
4
+ version: string | null;
5
+ sourcePath: string | null;
6
+ marketplace: {
7
+ name: string;
8
+ url: string | null;
9
+ ref: string | null;
10
+ } | null;
11
+ }
12
+ /** The directory holding this person's materialized plugins as `<dir>/<plugin name>`; undefined when none were materialized. */
13
+ export declare function materializedPluginsDir(person: string): string | undefined;
14
+ /** Install `installs` for `person` at `revision` into this process's plugin cache, once per (person, revision). When a plugin cannot
15
+ * be installed — a source on another machine, a marketplace that does not list it, a failed clone — the failure is held for that person
16
+ * and thrown by the next read of their plugins (`materializedPluginsDir`), so a request that never reads plugins, such as the one that
17
+ * fixes the settings, still works; the next load retries. */
18
+ export declare function materializePlugins(person: string, revision: number, installs: readonly PluginInstall[]): Promise<void>;