@pleri/olam-cli 0.1.180 → 0.1.185

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 (209) hide show
  1. package/dist/agent-stream/agent-sdk-to-chunks.js +44 -30
  2. package/dist/ask/checkout.d.ts +19 -0
  3. package/dist/ask/checkout.d.ts.map +1 -0
  4. package/dist/ask/checkout.js +40 -0
  5. package/dist/ask/checkout.js.map +1 -0
  6. package/dist/ask/knowledge-pack-builder.d.ts +72 -0
  7. package/dist/ask/knowledge-pack-builder.d.ts.map +1 -0
  8. package/dist/ask/knowledge-pack-builder.js +91 -0
  9. package/dist/ask/knowledge-pack-builder.js.map +1 -0
  10. package/dist/ask/knowledge-pack.generated.d.ts +8 -0
  11. package/dist/ask/knowledge-pack.generated.d.ts.map +1 -0
  12. package/dist/ask/knowledge-pack.generated.js +1947 -0
  13. package/dist/ask/knowledge-pack.generated.js.map +1 -0
  14. package/dist/ask/one-shot.d.ts +21 -0
  15. package/dist/ask/one-shot.d.ts.map +1 -0
  16. package/dist/ask/one-shot.js +50 -0
  17. package/dist/ask/one-shot.js.map +1 -0
  18. package/dist/ask/repl.d.ts +30 -0
  19. package/dist/ask/repl.d.ts.map +1 -0
  20. package/dist/ask/repl.js +109 -0
  21. package/dist/ask/repl.js.map +1 -0
  22. package/dist/ask/sdk-client.d.ts +87 -0
  23. package/dist/ask/sdk-client.d.ts.map +1 -0
  24. package/dist/ask/sdk-client.js +118 -0
  25. package/dist/ask/sdk-client.js.map +1 -0
  26. package/dist/ask/system-prompt.d.ts +30 -0
  27. package/dist/ask/system-prompt.d.ts.map +1 -0
  28. package/dist/ask/system-prompt.js +31 -0
  29. package/dist/ask/system-prompt.js.map +1 -0
  30. package/dist/commands/ask.d.ts +27 -0
  31. package/dist/commands/ask.d.ts.map +1 -0
  32. package/dist/commands/ask.js +63 -0
  33. package/dist/commands/ask.js.map +1 -0
  34. package/dist/commands/auth-list-json.d.ts +53 -0
  35. package/dist/commands/auth-list-json.d.ts.map +1 -0
  36. package/dist/commands/auth-list-json.js +47 -0
  37. package/dist/commands/auth-list-json.js.map +1 -0
  38. package/dist/commands/auth.d.ts.map +1 -1
  39. package/dist/commands/auth.js +80 -19
  40. package/dist/commands/auth.js.map +1 -1
  41. package/dist/commands/config.d.ts.map +1 -1
  42. package/dist/commands/config.js +93 -0
  43. package/dist/commands/config.js.map +1 -1
  44. package/dist/commands/destroy.d.ts +41 -0
  45. package/dist/commands/destroy.d.ts.map +1 -1
  46. package/dist/commands/destroy.js +81 -33
  47. package/dist/commands/destroy.js.map +1 -1
  48. package/dist/commands/dispatch-resolve.d.ts +54 -0
  49. package/dist/commands/dispatch-resolve.d.ts.map +1 -0
  50. package/dist/commands/dispatch-resolve.js +105 -0
  51. package/dist/commands/dispatch-resolve.js.map +1 -0
  52. package/dist/commands/dispatch.d.ts.map +1 -1
  53. package/dist/commands/dispatch.js +40 -9
  54. package/dist/commands/dispatch.js.map +1 -1
  55. package/dist/commands/doctor.js +11 -11
  56. package/dist/commands/doctor.js.map +1 -1
  57. package/dist/commands/flywheel/k5-validate.d.ts +31 -0
  58. package/dist/commands/flywheel/k5-validate.d.ts.map +1 -1
  59. package/dist/commands/flywheel/k5-validate.js +80 -19
  60. package/dist/commands/flywheel/k5-validate.js.map +1 -1
  61. package/dist/commands/keys-list-json.d.ts +55 -0
  62. package/dist/commands/keys-list-json.d.ts.map +1 -0
  63. package/dist/commands/keys-list-json.js +54 -0
  64. package/dist/commands/keys-list-json.js.map +1 -0
  65. package/dist/commands/keys.d.ts.map +1 -1
  66. package/dist/commands/keys.js +6 -0
  67. package/dist/commands/keys.js.map +1 -1
  68. package/dist/commands/kg-classify.d.ts.map +1 -1
  69. package/dist/commands/kg-classify.js +20 -0
  70. package/dist/commands/kg-classify.js.map +1 -1
  71. package/dist/commands/kg-doctor.d.ts +67 -6
  72. package/dist/commands/kg-doctor.d.ts.map +1 -1
  73. package/dist/commands/kg-doctor.js +126 -46
  74. package/dist/commands/kg-doctor.js.map +1 -1
  75. package/dist/commands/lanes-list-json.d.ts +69 -0
  76. package/dist/commands/lanes-list-json.d.ts.map +1 -0
  77. package/dist/commands/lanes-list-json.js +42 -0
  78. package/dist/commands/lanes-list-json.js.map +1 -0
  79. package/dist/commands/lanes.d.ts.map +1 -1
  80. package/dist/commands/lanes.js +18 -7
  81. package/dist/commands/lanes.js.map +1 -1
  82. package/dist/commands/list.d.ts +27 -0
  83. package/dist/commands/list.d.ts.map +1 -1
  84. package/dist/commands/list.js +67 -19
  85. package/dist/commands/list.js.map +1 -1
  86. package/dist/commands/memory/status.d.ts +18 -0
  87. package/dist/commands/memory/status.d.ts.map +1 -1
  88. package/dist/commands/memory/status.js +38 -2
  89. package/dist/commands/memory/status.js.map +1 -1
  90. package/dist/commands/memory-service-container.d.ts +44 -0
  91. package/dist/commands/memory-service-container.d.ts.map +1 -1
  92. package/dist/commands/memory-service-container.js +49 -0
  93. package/dist/commands/memory-service-container.js.map +1 -1
  94. package/dist/commands/plans-list-json.d.ts +77 -0
  95. package/dist/commands/plans-list-json.d.ts.map +1 -0
  96. package/dist/commands/plans-list-json.js +61 -0
  97. package/dist/commands/plans-list-json.js.map +1 -0
  98. package/dist/commands/plans.d.ts.map +1 -1
  99. package/dist/commands/plans.js +10 -0
  100. package/dist/commands/plans.js.map +1 -1
  101. package/dist/commands/ps.d.ts +32 -0
  102. package/dist/commands/ps.d.ts.map +1 -1
  103. package/dist/commands/ps.js +34 -0
  104. package/dist/commands/ps.js.map +1 -1
  105. package/dist/commands/repos-list-json.d.ts +58 -0
  106. package/dist/commands/repos-list-json.d.ts.map +1 -0
  107. package/dist/commands/repos-list-json.js +45 -0
  108. package/dist/commands/repos-list-json.js.map +1 -0
  109. package/dist/commands/repos.d.ts +1 -1
  110. package/dist/commands/repos.d.ts.map +1 -1
  111. package/dist/commands/repos.js +12 -2
  112. package/dist/commands/repos.js.map +1 -1
  113. package/dist/commands/runbooks.d.ts +32 -0
  114. package/dist/commands/runbooks.d.ts.map +1 -1
  115. package/dist/commands/runbooks.js +79 -22
  116. package/dist/commands/runbooks.js.map +1 -1
  117. package/dist/commands/services.d.ts +47 -1
  118. package/dist/commands/services.d.ts.map +1 -1
  119. package/dist/commands/services.js +59 -33
  120. package/dist/commands/services.js.map +1 -1
  121. package/dist/commands/skills-source.d.ts.map +1 -1
  122. package/dist/commands/skills-source.js +77 -2
  123. package/dist/commands/skills-source.js.map +1 -1
  124. package/dist/commands/skills.d.ts +27 -0
  125. package/dist/commands/skills.d.ts.map +1 -1
  126. package/dist/commands/skills.js +17 -2
  127. package/dist/commands/skills.js.map +1 -1
  128. package/dist/commands/upgrade-history.d.ts +0 -2
  129. package/dist/commands/upgrade-history.d.ts.map +1 -1
  130. package/dist/commands/upgrade-history.js +0 -6
  131. package/dist/commands/upgrade-history.js.map +1 -1
  132. package/dist/commands/upgrade-lock.d.ts +0 -9
  133. package/dist/commands/upgrade-lock.d.ts.map +1 -1
  134. package/dist/commands/upgrade-lock.js +1 -1
  135. package/dist/commands/upgrade-lock.js.map +1 -1
  136. package/dist/commands/workspace-list-json.d.ts +73 -0
  137. package/dist/commands/workspace-list-json.d.ts.map +1 -0
  138. package/dist/commands/workspace-list-json.js +59 -0
  139. package/dist/commands/workspace-list-json.js.map +1 -0
  140. package/dist/commands/workspace.d.ts.map +1 -1
  141. package/dist/commands/workspace.js +7 -1
  142. package/dist/commands/workspace.js.map +1 -1
  143. package/dist/commands/world-snapshot.d.ts +13 -0
  144. package/dist/commands/world-snapshot.d.ts.map +1 -1
  145. package/dist/commands/world-snapshot.js +81 -1
  146. package/dist/commands/world-snapshot.js.map +1 -1
  147. package/dist/commands/yolo.d.ts +0 -4
  148. package/dist/commands/yolo.d.ts.map +1 -1
  149. package/dist/commands/yolo.js +2 -2
  150. package/dist/commands/yolo.js.map +1 -1
  151. package/dist/image-digests.json +8 -8
  152. package/dist/index.js +6097 -2563
  153. package/dist/index.js.map +1 -1
  154. package/dist/lib/anthropic-base-url-file.d.ts +37 -0
  155. package/dist/lib/anthropic-base-url-file.d.ts.map +1 -0
  156. package/dist/lib/anthropic-base-url-file.js +46 -0
  157. package/dist/lib/anthropic-base-url-file.js.map +1 -0
  158. package/dist/lib/auth-remote.d.ts +9 -0
  159. package/dist/lib/auth-remote.d.ts.map +1 -1
  160. package/dist/lib/auth-remote.js +19 -4
  161. package/dist/lib/auth-remote.js.map +1 -1
  162. package/dist/lib/cf-access-token.d.ts +32 -0
  163. package/dist/lib/cf-access-token.d.ts.map +1 -0
  164. package/dist/lib/cf-access-token.js +52 -0
  165. package/dist/lib/cf-access-token.js.map +1 -0
  166. package/dist/lib/config.d.ts +17 -3
  167. package/dist/lib/config.d.ts.map +1 -1
  168. package/dist/lib/config.js +28 -4
  169. package/dist/lib/config.js.map +1 -1
  170. package/dist/lib/k8s-bootstrap.d.ts.map +1 -1
  171. package/dist/lib/k8s-bootstrap.js +13 -1
  172. package/dist/lib/k8s-bootstrap.js.map +1 -1
  173. package/dist/lib/k8s-secret-render.d.ts +2 -0
  174. package/dist/lib/k8s-secret-render.d.ts.map +1 -1
  175. package/dist/lib/k8s-secret-render.js +27 -0
  176. package/dist/lib/k8s-secret-render.js.map +1 -1
  177. package/dist/lib/kubectl-context.d.ts +49 -0
  178. package/dist/lib/kubectl-context.d.ts.map +1 -1
  179. package/dist/lib/kubectl-context.js +64 -2
  180. package/dist/lib/kubectl-context.js.map +1 -1
  181. package/dist/lib/peripheral-registry.d.ts +1 -1
  182. package/dist/lib/peripheral-registry.d.ts.map +1 -1
  183. package/dist/lib/peripheral-registry.js +13 -0
  184. package/dist/lib/peripheral-registry.js.map +1 -1
  185. package/dist/lib/upgrade-kubernetes.d.ts +13 -0
  186. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  187. package/dist/lib/upgrade-kubernetes.js +42 -9
  188. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  189. package/dist/mcp-server.js +2624 -1041
  190. package/hermes-bundle/version.json +1 -1
  191. package/host-cp/k8s/manifests/30-configmap.yaml +11 -6
  192. package/host-cp/k8s/manifests/45-pvc.yaml +6 -2
  193. package/host-cp/k8s/manifests/50-deployment.yaml +15 -1
  194. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  195. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  196. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  197. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  198. package/host-cp/k8s/templates/chunks-postgres-secret-template.yaml +24 -0
  199. package/host-cp/k8s/templates/plan-chat-service-secret-template.yaml +35 -0
  200. package/host-cp/observability/trace-summary.mjs +267 -0
  201. package/host-cp/src/bootstrap-selective.mjs +30 -28
  202. package/host-cp/src/host-stream.mjs +52 -0
  203. package/host-cp/src/plan-chat-service.mjs +99 -74
  204. package/host-cp/src/redirect.mjs +7 -0
  205. package/host-cp/src/router.mjs +168 -0
  206. package/host-cp/src/serve-only-config.mjs +85 -0
  207. package/host-cp/src/server.mjs +482 -217
  208. package/host-cp/src/world-services.mjs +136 -0
  209. package/package.json +4 -2
@@ -327,88 +327,113 @@ export function createHandler({
327
327
 
328
328
  // B4 — capture message_usage when the adapter forwarded token usage.
329
329
  // body.usage is optional (only present on assistant turns with SDK usage).
330
+ //
331
+ // perf: SQL-2 through SQL-5 (INSERT message_usage, SUM, dedup check,
332
+ // INSERT threshold chunk) are batched inside a single BEGIN/COMMIT so
333
+ // the 4 sequential round-trips share one connection checkout and one
334
+ // server-side transaction flush instead of 4 independent pool checkouts.
335
+ // SQL-1 (INSERT chunks above) stays outside the txn so its 23505 dedup
336
+ // guard reaches the caller independently as a 409 — folding it in would
337
+ // cause a threshold-chunk PK conflict to silently roll back the primary
338
+ // chunk write.
330
339
  if (body.usage && typeof body.usage === 'object') {
331
340
  const usage = body.usage;
332
341
  const model = typeof body.model === 'string' && body.model.length > 0
333
342
  ? body.model
334
343
  : 'claude-sonnet-4-6';
335
- await pool.query(
336
- `INSERT INTO message_usage
337
- (world_id, session_id, message_id, actor_id, model,
338
- input_tokens, output_tokens, cache_read_tokens, cache_create_tokens)
339
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
340
- ON CONFLICT (message_id, actor_id) DO NOTHING`,
341
- [
342
- body.world_id,
343
- body.session_id,
344
- body.message_id,
345
- principal.actorId,
346
- model,
347
- Number(usage.input_tokens ?? 0),
348
- Number(usage.output_tokens ?? 0),
349
- Number(usage.cache_read_input_tokens ?? 0),
350
- Number(usage.cache_creation_input_tokens ?? 0),
351
- ],
352
- );
353
-
354
- // B6 — 80% threshold dedup'd system chunk.
355
- // After inserting message_usage, check if the per-actor cumulative
356
- // token count for this session has crossed 80% of the model's context
357
- // cap. If so, and no prior threshold chunk exists for (session_id,
358
- // actor_id), emit exactly one dedup'd system chunk.
359
- //
360
- // Token math: sum(input_tokens + cache_read_tokens + cache_create_tokens)
361
- // per (session_id, actor_id), matching Claude Code statusline.
362
- const contextCap = CONTEXT_CAPS_LOCAL[model] ?? DEFAULT_CONTEXT_CAP;
363
- const sumResult = await pool.query(
364
- `SELECT COALESCE(SUM(input_tokens + cache_read_tokens + cache_create_tokens), 0) AS total_used
365
- FROM message_usage
366
- WHERE session_id = $1 AND actor_id = $2`,
367
- [body.session_id, principal.actorId],
368
- );
369
- const totalUsed = Number(sumResult.rows[0]?.total_used ?? 0);
370
- const usedPct = Math.floor((totalUsed / contextCap) * 100);
371
-
372
- if (usedPct >= THRESHOLD_PCT) {
373
- // Dedup check: scan chunks for an existing threshold-crossing system
374
- // chunk for this (session_id, actor_id). Prefix-match is sufficient
375
- // because THRESHOLD_CHUNK_PREFIX is unique to this purpose.
376
- const dupResult = await pool.query(
377
- `SELECT 1 FROM chunks
378
- WHERE session_id = $1 AND actor_id = $2 AND actor_type = 'system'
379
- AND chunk LIKE $3
380
- LIMIT 1`,
381
- [body.session_id, principal.actorId, `${THRESHOLD_CHUNK_PREFIX}%`],
344
+ const client = await pool.connect();
345
+ try {
346
+ await client.query('BEGIN');
347
+
348
+ // SQL-2 INSERT message_usage
349
+ await client.query(
350
+ `INSERT INTO message_usage
351
+ (world_id, session_id, message_id, actor_id, model,
352
+ input_tokens, output_tokens, cache_read_tokens, cache_create_tokens)
353
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
354
+ ON CONFLICT (message_id, actor_id) DO NOTHING`,
355
+ [
356
+ body.world_id,
357
+ body.session_id,
358
+ body.message_id,
359
+ principal.actorId,
360
+ model,
361
+ Number(usage.input_tokens ?? 0),
362
+ Number(usage.output_tokens ?? 0),
363
+ Number(usage.cache_read_input_tokens ?? 0),
364
+ Number(usage.cache_creation_input_tokens ?? 0),
365
+ ],
382
366
  );
383
- const alreadyEmitted = (dupResult.rows ?? []).length > 0;
384
-
385
- if (!alreadyEmitted) {
386
- const personaLabel = principal.actorId;
387
- const thresholdChunk =
388
- `${THRESHOLD_CHUNK_PREFIX} ${personaLabel} at ${usedPct}%`;
389
- // Emit the threshold system chunk. Re-use the same message_id +
390
- // a seq derived from the current seq + 1 to avoid PK conflict.
391
- // Use body.seq + 1000 as a high-offset seq so it sorts AFTER the
392
- // triggering chunk but doesn't collide with normal seq values.
393
- const thresholdSeq = body.seq + 1000;
394
- await pool.query(
395
- `INSERT INTO chunks
396
- (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
397
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
398
- ON CONFLICT (message_id, seq) DO NOTHING`,
399
- [
400
- body.world_id,
401
- body.session_id,
402
- body.message_id,
403
- thresholdSeq,
404
- principal.actorId,
405
- 'system',
406
- 'system',
407
- thresholdChunk,
408
- 'text',
409
- ],
367
+
368
+ // B6 — 80% threshold dedup'd system chunk.
369
+ // After inserting message_usage, check if the per-actor cumulative
370
+ // token count for this session has crossed 80% of the model's context
371
+ // cap. If so, and no prior threshold chunk exists for (session_id,
372
+ // actor_id), emit exactly one dedup'd system chunk.
373
+ //
374
+ // Token math: sum(input_tokens + cache_read_tokens + cache_create_tokens)
375
+ // per (session_id, actor_id), matching Claude Code statusline.
376
+ //
377
+ // SQL-3 SUM cumulative tokens (reads within the same txn so the
378
+ // just-inserted usage row is visible without waiting for COMMIT).
379
+ const contextCap = CONTEXT_CAPS_LOCAL[model] ?? DEFAULT_CONTEXT_CAP;
380
+ const sumResult = await client.query(
381
+ `SELECT COALESCE(SUM(input_tokens + cache_read_tokens + cache_create_tokens), 0) AS total_used
382
+ FROM message_usage
383
+ WHERE session_id = $1 AND actor_id = $2`,
384
+ [body.session_id, principal.actorId],
385
+ );
386
+ const totalUsed = Number(sumResult.rows[0]?.total_used ?? 0);
387
+ const usedPct = Math.floor((totalUsed / contextCap) * 100);
388
+
389
+ if (usedPct >= THRESHOLD_PCT) {
390
+ // SQL-4 — dedup check: scan chunks for an existing threshold-crossing
391
+ // system chunk for this (session_id, actor_id). Prefix-match is
392
+ // sufficient because THRESHOLD_CHUNK_PREFIX is unique to this purpose.
393
+ const dupResult = await client.query(
394
+ `SELECT 1 FROM chunks
395
+ WHERE session_id = $1 AND actor_id = $2 AND actor_type = 'system'
396
+ AND chunk LIKE $3
397
+ LIMIT 1`,
398
+ [body.session_id, principal.actorId, `${THRESHOLD_CHUNK_PREFIX}%`],
410
399
  );
400
+ const alreadyEmitted = (dupResult.rows ?? []).length > 0;
401
+
402
+ if (!alreadyEmitted) {
403
+ const personaLabel = principal.actorId;
404
+ const thresholdChunk =
405
+ `${THRESHOLD_CHUNK_PREFIX} ${personaLabel} at ${usedPct}%`;
406
+ // SQL-5 — Emit the threshold system chunk. Re-use the same message_id +
407
+ // a seq derived from the current seq + 1 to avoid PK conflict.
408
+ // Use body.seq + 1000 as a high-offset seq so it sorts AFTER the
409
+ // triggering chunk but doesn't collide with normal seq values.
410
+ const thresholdSeq = body.seq + 1000;
411
+ await client.query(
412
+ `INSERT INTO chunks
413
+ (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
414
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
415
+ ON CONFLICT (message_id, seq) DO NOTHING`,
416
+ [
417
+ body.world_id,
418
+ body.session_id,
419
+ body.message_id,
420
+ thresholdSeq,
421
+ principal.actorId,
422
+ 'system',
423
+ 'system',
424
+ thresholdChunk,
425
+ 'text',
426
+ ],
427
+ );
428
+ }
411
429
  }
430
+
431
+ await client.query('COMMIT');
432
+ } catch (txnErr) {
433
+ await client.query('ROLLBACK').catch(() => undefined);
434
+ throw txnErr;
435
+ } finally {
436
+ client.release();
412
437
  }
413
438
  }
414
439
  // H2 (plan-chat-spa-canonical-surface Phase G) — extract commit_plan /
@@ -76,6 +76,13 @@ export function evaluateRedirect(pathname) {
76
76
  };
77
77
  }
78
78
 
79
+ // /design → / (Phase E2: the DesignSurface alpha placeholder is retired.
80
+ // Hardcoded target — no caller reflection. Exact-match only so /designfoo
81
+ // or /design/sub do not over-match into the redirect.)
82
+ if (pathname === '/design' || pathname === '/design/') {
83
+ return { kind: 'redirect', status: 301, location: '/' };
84
+ }
85
+
79
86
  // /world/:id (catch-all, EXCLUDING /editor and /events sub-routes)
80
87
  // /sandbox/:id (catch-all, EXCLUDING /editor and /events sub-routes)
81
88
  const worldMatch = /^\/(world|sandbox)\/([^/]+)(\/.*)?$/.exec(pathname);
@@ -0,0 +1,168 @@
1
+ // host-cp request router.
2
+ //
3
+ // Replaces the long linear `if (url.pathname === ...)` dispatch chain in
4
+ // server.mjs with an ordered route table. The table is walked in
5
+ // registration order, so route PRECEDENCE is preserved exactly as it was
6
+ // in the original if-ladder: the first matching route wins, later routes
7
+ // are never consulted once a match handles the request.
8
+ //
9
+ // Why a table and not a framework:
10
+ // - host-cp ships with no external HTTP framework (no express/fastify);
11
+ // this matches the existing zero-dep style.
12
+ // - The table is a plain data structure, so it is importable + unit
13
+ // testable WITHOUT booting server.mjs (which spawns docker-events,
14
+ // the auth poller, and the worlds.db reconciler at import time).
15
+ // - A route is now a table entry instead of a `return` buried in a
16
+ // 1700-line ladder. That kills the silent route-shadowing class: a
17
+ // misplaced `return` can no longer swallow a later route, and the
18
+ // full set of routes is enumerable (see `router.routes()`).
19
+ //
20
+ // Behavior-preservation contract (load-bearing — see
21
+ // __tests__/router.test.mjs):
22
+ // 1. Walk order == registration order == original source order.
23
+ // 2. A route MATCHES when its matcher returns a truthy match value AND
24
+ // (no method filter OR the method matches). The matcher receives
25
+ // ({ pathname, method, url }) and returns either a boolean or, for
26
+ // regex routes, the RegExpMatchArray (truthy) so the handler can read
27
+ // capture groups.
28
+ // 3. The FIRST matching route is invoked and dispatch STOPS — identical
29
+ // to `if (cond) { ...; return; }`. The handler owns the response.
30
+ // 4. A route whose path matches but whose METHOD does not is SKIPPED,
31
+ // and the walk continues — identical to the original
32
+ // `if (pathMatch && req.method === 'X')` blocks, where a path hit
33
+ // with the wrong method fell through to the next `if`.
34
+ // 5. If no route matches, dispatch returns `false` so the caller runs
35
+ // its terminal 404 — identical to the original fall-through.
36
+ //
37
+ // The router does NOT add auth, body parsing, or any middleware semantics.
38
+ // Those stay exactly where they were in server.mjs (pre-auth routes, the
39
+ // auth gate, the plan-chat bypass) — the router only models the part of
40
+ // the chain that was a flat sequence of `if` blocks.
41
+
42
+ /**
43
+ * @typedef {object} RouteContext
44
+ * @property {string} pathname url.pathname
45
+ * @property {string} method req.method (already normalized by node to uppercase)
46
+ * @property {URL} url parsed request URL
47
+ */
48
+
49
+ /**
50
+ * A matcher decides whether a route applies to a request, ignoring method.
51
+ * Returning a non-boolean truthy value (e.g. a RegExpMatchArray) is
52
+ * forwarded to the handler as `ctx.match` so regex routes can read groups.
53
+ *
54
+ * @typedef {(ctx: RouteContext) => (boolean | RegExpMatchArray | null | undefined)} RouteMatcher
55
+ */
56
+
57
+ /**
58
+ * A handler receives the node req/res plus the parsed url, the matched
59
+ * value (for regex routes), and is responsible for writing the response.
60
+ * It mirrors the body of an original `if` block. Return value is ignored;
61
+ * matching alone terminates dispatch (preserving the `if ... return`
62
+ * semantics where reaching the block always handled the request).
63
+ *
64
+ * @typedef {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, ctx: RouteContext & { match: any }) => unknown | Promise<unknown>} RouteHandler
65
+ */
66
+
67
+ /**
68
+ * @typedef {object} Route
69
+ * @property {string} name human label for diagnostics / tests
70
+ * @property {string[] | null} methods allowed methods, or null for "any method"
71
+ * @property {RouteMatcher} match
72
+ * @property {RouteHandler} handler
73
+ */
74
+
75
+ /**
76
+ * Create an ordered router. Routes are matched in the order they are
77
+ * registered — register in the SAME order the original if-ladder ran.
78
+ */
79
+ export function createRouter() {
80
+ /** @type {Route[]} */
81
+ const routes = [];
82
+
83
+ /**
84
+ * Register a route. Returns the router for chaining.
85
+ *
86
+ * @param {object} spec
87
+ * @param {string} spec.name
88
+ * @param {string | string[] | null} [spec.method] single method, list, or null/omitted for any
89
+ * @param {string} [spec.path] exact pathname match (mutually exclusive with prefix/match)
90
+ * @param {string} [spec.prefix] pathname.startsWith(prefix) match
91
+ * @param {RegExp} [spec.pattern] pathname.match(pattern) — match value passed to handler
92
+ * @param {RouteMatcher} [spec.match] custom matcher (overrides path/prefix/pattern)
93
+ * @param {RouteHandler} spec.handler
94
+ */
95
+ function register(spec) {
96
+ const { name, method, path, prefix, pattern } = spec;
97
+ const handler = spec.handler;
98
+ if (typeof handler !== 'function') {
99
+ throw new TypeError(`route "${name}" requires a handler function`);
100
+ }
101
+
102
+ /** @type {string[] | null} */
103
+ let methods = null;
104
+ if (Array.isArray(method)) methods = method.slice();
105
+ else if (typeof method === 'string') methods = [method];
106
+ // method omitted or null → any method
107
+
108
+ /** @type {RouteMatcher} */
109
+ let match;
110
+ if (typeof spec.match === 'function') {
111
+ match = spec.match;
112
+ } else if (typeof path === 'string') {
113
+ match = (ctx) => ctx.pathname === path;
114
+ } else if (typeof prefix === 'string') {
115
+ match = (ctx) => ctx.pathname.startsWith(prefix);
116
+ } else if (pattern instanceof RegExp) {
117
+ match = (ctx) => ctx.pathname.match(pattern);
118
+ } else {
119
+ throw new TypeError(
120
+ `route "${name}" requires one of: path, prefix, pattern, or match`,
121
+ );
122
+ }
123
+
124
+ routes.push({ name, methods, match, handler });
125
+ return api;
126
+ }
127
+
128
+ /**
129
+ * Walk the table in registration order. Invokes the first route whose
130
+ * matcher is truthy AND whose method filter admits the request, then
131
+ * stops. A path-match with a non-admitted method is skipped (the walk
132
+ * continues), preserving the original `if (pathMatch && method===X)`
133
+ * fall-through.
134
+ *
135
+ * @param {import('node:http').IncomingMessage} req
136
+ * @param {import('node:http').ServerResponse} res
137
+ * @param {URL} url
138
+ * @returns {Promise<boolean>} true if a route handled the request, false to fall through to 404
139
+ */
140
+ async function dispatch(req, res, url) {
141
+ const ctx = { pathname: url.pathname, method: req.method ?? 'GET', url };
142
+ for (const route of routes) {
143
+ const matched = route.match(ctx);
144
+ if (!matched) continue;
145
+ // Path matched. Now gate on method — a mismatch is a SKIP, not a
146
+ // 405, exactly mirroring the original if-ladder fall-through.
147
+ if (route.methods !== null && !route.methods.includes(ctx.method)) {
148
+ continue;
149
+ }
150
+ await route.handler(req, res, { ...ctx, match: matched });
151
+ return true;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Enumerate registered routes (name + methods + matcher kind) for
158
+ * diagnostics, audits, and tests. Pure read of the table.
159
+ *
160
+ * @returns {Array<{ name: string, methods: string[] | null }>}
161
+ */
162
+ function list() {
163
+ return routes.map((r) => ({ name: r.name, methods: r.methods }));
164
+ }
165
+
166
+ const api = { register, dispatch, list, get size() { return routes.length; } };
167
+ return api;
168
+ }
@@ -0,0 +1,85 @@
1
+ // serve-only-config.mjs — host-cp SERVE-ONLY mode gate (Phase A of
2
+ // host-cp-gke-serve-only-mode).
3
+ //
4
+ // host-cp normally runs as a local operator sidecar coupled to the host's
5
+ // docker daemon + operator-repo + gh-config. On a managed GKE cluster those
6
+ // host-couplings are absent: host-cp only serves plan-chat-spa + the
7
+ // host-native `/api/*` surface; world orchestration runs elsewhere.
8
+ //
9
+ // `OLAM_HOST_CP_SERVE_ONLY=true` switches host-cp into that degraded shape:
10
+ // - no docker transport connect, no world discovery
11
+ // - no PlanOrchestrator docker wiring, no pr-merge-poller docker/repo deps
12
+ // - world-orchestration routes (`/api/world/*`) return a structured 503
13
+ // - version-status degrades to 'unknown' (no operator-repo)
14
+ //
15
+ // The flag defaults OFF — the local docker/k3d FULL mode is byte-for-byte
16
+ // unchanged. This module is a tiny pure seam so the gate decision can be
17
+ // unit-tested WITHOUT booting server.mjs (which connects docker + binds a
18
+ // port at module load and therefore can't be imported in a test).
19
+ //
20
+ // ONE coarse flag — no granular per-subsystem toggles (plan S1 / YAGNI).
21
+
22
+ /**
23
+ * Decide whether host-cp runs in SERVE-ONLY mode.
24
+ *
25
+ * Strict `=== 'true'` parse (mirrors the HOST_CP_MODE env-flag convention
26
+ * in server.mjs): only the literal string `'true'` enables it. Any other
27
+ * value — unset, `'1'`, `'false'`, `''`, `'TRUE'` — keeps FULL mode so the
28
+ * default stays OFF and operators can't half-enable it by accident.
29
+ *
30
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
31
+ * Environment to read `OLAM_HOST_CP_SERVE_ONLY` from. Defaults to
32
+ * `process.env`.
33
+ * @returns {boolean} `true` when serve-only mode is active.
34
+ */
35
+ export function isServeOnly(env = process.env) {
36
+ return env?.OLAM_HOST_CP_SERVE_ONLY === 'true';
37
+ }
38
+
39
+ /**
40
+ * Structured 503 body for world-orchestration routes that are unavailable
41
+ * in serve-only mode. Reuses the host-cp `/api/*` JSON-error shape
42
+ * (`{ error, message }`) so SPA error handling treats it uniformly.
43
+ *
44
+ * @type {{ error: 'orchestration_unavailable', message: string }}
45
+ */
46
+ export const ORCHESTRATION_UNAVAILABLE = Object.freeze({
47
+ error: 'orchestration_unavailable',
48
+ message:
49
+ 'host-cp is in serve-only mode (managed cluster); world orchestration runs elsewhere',
50
+ });
51
+
52
+ /**
53
+ * True when `pathname` (+ `method`) is a world-ORCHESTRATION route that must
54
+ * degrade to a structured 503 in serve-only mode. The surface is wider than
55
+ * the singular `/api/world/` proxy: it also covers the plural `/api/worlds/`
56
+ * per-world mutation/read routes (e.g. `POST /api/worlds/<id>/tunnels` which
57
+ * spawns a real cloudflare tunnel, `DELETE /api/worlds/<id>` which destroys a
58
+ * world), world creation (`POST /api/worlds`), and the CLI `/v1/worlds/`
59
+ * routes. Without this breadth a serve-only host-cp on a shared cluster would
60
+ * execute tunnel/destroy mutations — the opposite of honest degradation.
61
+ * (CP3 finding: the singular-only guard let POST /api/worlds/<id>/tunnels
62
+ * open a live public tunnel in serve-only.)
63
+ *
64
+ * Deliberately NOT orchestration: `GET`/`HEAD /api/worlds` (the bare LIST
65
+ * endpoint) — it returns an empty array in serve-only, which is honest.
66
+ *
67
+ * @param {unknown} pathname URL.pathname (no querystring).
68
+ * @param {string} [method] HTTP method (defaults 'GET').
69
+ * @returns {boolean}
70
+ */
71
+ export function isOrchestrationRoute(pathname, method = 'GET') {
72
+ if (typeof pathname !== 'string') return false;
73
+ // Singular /api/world/<id>/... — the per-world CP proxy + /progress.
74
+ if (pathname.startsWith('/api/world/')) return true;
75
+ // CLI per-world routes (olam status/logs <world>).
76
+ if (pathname.startsWith('/v1/worlds/')) return true;
77
+ // Plural /api/worlds:
78
+ // bare LIST (GET/HEAD /api/worlds) → honest [] in serve-only, NOT blocked.
79
+ // create (POST /api/worlds) + any per-world subpath (/api/worlds/<id>...) → 503.
80
+ if (pathname === '/api/worlds') {
81
+ return method !== 'GET' && method !== 'HEAD';
82
+ }
83
+ if (/^\/api\/worlds\/[^/?#]+/.test(pathname)) return true;
84
+ return false;
85
+ }