@pleri/olam-cli 0.1.188 → 0.1.195

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 (156) hide show
  1. package/README.md +1 -1
  2. package/dist/ask/knowledge-pack.generated.d.ts.map +1 -1
  3. package/dist/ask/knowledge-pack.generated.js +37 -12
  4. package/dist/ask/knowledge-pack.generated.js.map +1 -1
  5. package/dist/commands/bootstrap.d.ts +4 -0
  6. package/dist/commands/bootstrap.d.ts.map +1 -1
  7. package/dist/commands/bootstrap.js +6 -9
  8. package/dist/commands/bootstrap.js.map +1 -1
  9. package/dist/commands/clean.js +1 -1
  10. package/dist/commands/clean.js.map +1 -1
  11. package/dist/commands/completion.d.ts.map +1 -1
  12. package/dist/commands/completion.js +1 -4
  13. package/dist/commands/completion.js.map +1 -1
  14. package/dist/commands/create.d.ts.map +1 -1
  15. package/dist/commands/create.js +6 -0
  16. package/dist/commands/create.js.map +1 -1
  17. package/dist/commands/crystallize.js +12 -14
  18. package/dist/commands/crystallize.js.map +1 -1
  19. package/dist/commands/destroy.d.ts +13 -1
  20. package/dist/commands/destroy.d.ts.map +1 -1
  21. package/dist/commands/destroy.js +52 -6
  22. package/dist/commands/destroy.js.map +1 -1
  23. package/dist/commands/dispatch.d.ts +9 -0
  24. package/dist/commands/dispatch.d.ts.map +1 -1
  25. package/dist/commands/dispatch.js +21 -2
  26. package/dist/commands/dispatch.js.map +1 -1
  27. package/dist/commands/doctor.d.ts +1 -1
  28. package/dist/commands/doctor.d.ts.map +1 -1
  29. package/dist/commands/doctor.js +29 -22
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/commands/enter.d.ts +3 -3
  32. package/dist/commands/enter.d.ts.map +1 -1
  33. package/dist/commands/enter.js +57 -44
  34. package/dist/commands/enter.js.map +1 -1
  35. package/dist/commands/flywheel/index.d.ts.map +1 -1
  36. package/dist/commands/flywheel/index.js +1 -1
  37. package/dist/commands/flywheel/index.js.map +1 -1
  38. package/dist/commands/host-cp.d.ts.map +1 -1
  39. package/dist/commands/host-cp.js +2 -1
  40. package/dist/commands/host-cp.js.map +1 -1
  41. package/dist/commands/implode.d.ts.map +1 -1
  42. package/dist/commands/implode.js +1 -1
  43. package/dist/commands/implode.js.map +1 -1
  44. package/dist/commands/init.d.ts +20 -0
  45. package/dist/commands/init.d.ts.map +1 -1
  46. package/dist/commands/init.js +102 -9
  47. package/dist/commands/init.js.map +1 -1
  48. package/dist/commands/kg-build.d.ts.map +1 -1
  49. package/dist/commands/kg-build.js +3 -0
  50. package/dist/commands/kg-build.js.map +1 -1
  51. package/dist/commands/kg-classify.d.ts +20 -0
  52. package/dist/commands/kg-classify.d.ts.map +1 -1
  53. package/dist/commands/kg-classify.js +59 -42
  54. package/dist/commands/kg-classify.js.map +1 -1
  55. package/dist/commands/kg-mirror.d.ts +40 -0
  56. package/dist/commands/kg-mirror.d.ts.map +1 -0
  57. package/dist/commands/kg-mirror.js +228 -0
  58. package/dist/commands/kg-mirror.js.map +1 -0
  59. package/dist/commands/mcp/index.js +1 -1
  60. package/dist/commands/mcp/index.js.map +1 -1
  61. package/dist/commands/memory/index.d.ts.map +1 -1
  62. package/dist/commands/memory/index.js +1 -1
  63. package/dist/commands/memory/index.js.map +1 -1
  64. package/dist/commands/resume.d.ts.map +1 -1
  65. package/dist/commands/resume.js +1 -1
  66. package/dist/commands/resume.js.map +1 -1
  67. package/dist/commands/services-tls.d.ts +120 -0
  68. package/dist/commands/services-tls.d.ts.map +1 -0
  69. package/dist/commands/services-tls.js +434 -0
  70. package/dist/commands/services-tls.js.map +1 -0
  71. package/dist/commands/services.d.ts.map +1 -1
  72. package/dist/commands/services.js +28 -1
  73. package/dist/commands/services.js.map +1 -1
  74. package/dist/commands/setup-linux-gate.d.ts.map +1 -1
  75. package/dist/commands/setup-linux-gate.js +1 -3
  76. package/dist/commands/setup-linux-gate.js.map +1 -1
  77. package/dist/commands/setup-metrics.d.ts.map +1 -1
  78. package/dist/commands/setup-metrics.js +1 -2
  79. package/dist/commands/setup-metrics.js.map +1 -1
  80. package/dist/commands/setup-phase-5a-skill-source.d.ts +17 -1
  81. package/dist/commands/setup-phase-5a-skill-source.d.ts.map +1 -1
  82. package/dist/commands/setup-phase-5a-skill-source.js +69 -6
  83. package/dist/commands/setup-phase-5a-skill-source.js.map +1 -1
  84. package/dist/commands/setup.d.ts +26 -1
  85. package/dist/commands/setup.d.ts.map +1 -1
  86. package/dist/commands/setup.js +189 -47
  87. package/dist/commands/setup.js.map +1 -1
  88. package/dist/commands/skills-onboard.d.ts.map +1 -1
  89. package/dist/commands/skills-onboard.js +4 -1
  90. package/dist/commands/skills-onboard.js.map +1 -1
  91. package/dist/commands/skills-source.d.ts.map +1 -1
  92. package/dist/commands/skills-source.js +20 -4
  93. package/dist/commands/skills-source.js.map +1 -1
  94. package/dist/commands/status.js +1 -1
  95. package/dist/commands/status.js.map +1 -1
  96. package/dist/commands/upgrade.d.ts.map +1 -1
  97. package/dist/commands/upgrade.js +1 -3
  98. package/dist/commands/upgrade.js.map +1 -1
  99. package/dist/commands/yolo.d.ts.map +1 -1
  100. package/dist/commands/yolo.js +1 -1
  101. package/dist/commands/yolo.js.map +1 -1
  102. package/dist/context.d.ts +4 -0
  103. package/dist/context.d.ts.map +1 -1
  104. package/dist/context.js +3 -2
  105. package/dist/context.js.map +1 -1
  106. package/dist/image-digests.json +8 -8
  107. package/dist/index.js +3846 -2232
  108. package/dist/index.js.map +1 -1
  109. package/dist/lib/auth-refresh-kubernetes.d.ts.map +1 -1
  110. package/dist/lib/auth-refresh-kubernetes.js +14 -5
  111. package/dist/lib/auth-refresh-kubernetes.js.map +1 -1
  112. package/dist/lib/bootstrap-kubernetes.d.ts +41 -0
  113. package/dist/lib/bootstrap-kubernetes.d.ts.map +1 -1
  114. package/dist/lib/bootstrap-kubernetes.js +289 -36
  115. package/dist/lib/bootstrap-kubernetes.js.map +1 -1
  116. package/dist/lib/cf-access-token.d.ts.map +1 -1
  117. package/dist/lib/cf-access-token.js +2 -3
  118. package/dist/lib/cf-access-token.js.map +1 -1
  119. package/dist/lib/help-groups.d.ts +36 -0
  120. package/dist/lib/help-groups.d.ts.map +1 -0
  121. package/dist/lib/help-groups.js +124 -0
  122. package/dist/lib/help-groups.js.map +1 -0
  123. package/dist/lib/k8s-bootstrap.d.ts +6 -0
  124. package/dist/lib/k8s-bootstrap.d.ts.map +1 -1
  125. package/dist/lib/k8s-bootstrap.js +15 -2
  126. package/dist/lib/k8s-bootstrap.js.map +1 -1
  127. package/dist/lib/k8s-secret-render.d.ts.map +1 -1
  128. package/dist/lib/k8s-secret-render.js +17 -10
  129. package/dist/lib/k8s-secret-render.js.map +1 -1
  130. package/dist/lib/memory-secret.d.ts +15 -2
  131. package/dist/lib/memory-secret.d.ts.map +1 -1
  132. package/dist/lib/memory-secret.js +25 -8
  133. package/dist/lib/memory-secret.js.map +1 -1
  134. package/dist/lib/upgrade-check.d.ts +60 -0
  135. package/dist/lib/upgrade-check.d.ts.map +1 -0
  136. package/dist/lib/upgrade-check.js +169 -0
  137. package/dist/lib/upgrade-check.js.map +1 -0
  138. package/dist/lib/upgrade-kubernetes.d.ts +17 -0
  139. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  140. package/dist/lib/upgrade-kubernetes.js +125 -1
  141. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  142. package/dist/mcp-server.js +2651 -2850
  143. package/hermes-bundle/version.json +1 -1
  144. package/host-cp/k8s/manifests/30-configmap.yaml +8 -1
  145. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  146. package/host-cp/k8s/manifests/60-service.yaml +12 -4
  147. package/host-cp/k8s/manifests/70-ingressroute.yaml +58 -0
  148. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  149. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  150. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  151. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  152. package/host-cp/src/plan-chat-secret.mjs +16 -1
  153. package/host-cp/src/plan-chat-service.mjs +493 -11
  154. package/host-cp/src/planning-sessions.mjs +252 -0
  155. package/host-cp/src/server.mjs +92 -2
  156. package/package.json +2 -1
@@ -34,8 +34,18 @@ import { performance } from 'node:perf_hooks';
34
34
  import { Readable } from 'node:stream';
35
35
  import { URL } from 'node:url';
36
36
  import pg from 'pg';
37
+ import { SCHEMA_SQL } from '@olam/chunks/schema';
37
38
  import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
38
- import { listPlanningSessions } from './planning-sessions.mjs';
39
+ import {
40
+ listPlanningSessions,
41
+ createDispatchSession,
42
+ claimDispatchTurnLock,
43
+ clearDispatchTurnLock,
44
+ getDispatchSession,
45
+ haltDispatchSession,
46
+ reactivateDispatchSession,
47
+ listDispatchSessions,
48
+ } from './planning-sessions.mjs';
39
49
  import { crystallizePlanningSession } from './crystallize-planning.mjs';
40
50
  import { resolveId, RESOLVE_ID_RE, createRateLimiter } from './resolver.mjs';
41
51
 
@@ -69,7 +79,10 @@ const SCOPE_ID_RE = /^[A-Za-z0-9_.-]+$/;
69
79
  // /v1/shape. Only these tables have server-side where-rewrite support; any
70
80
  // other table=... param gets a 400. Guards against a client enumerating
71
81
  // tables the service doesn't own.
72
- const ALLOWED_SHAPE_TABLES = new Set(['chunks', 'message_usage', 'planning_artifacts']);
82
+ // planning_sessions added in Phase C C-S1 so useDispatchSessions can subscribe
83
+ // via Electric long-poll. The handleGetShape `where` rewriter uses world_id +
84
+ // session_id; planning_sessions rows carry world_id (added in Phase A A1).
85
+ const ALLOWED_SHAPE_TABLES = new Set(['chunks', 'message_usage', 'planning_artifacts', 'planning_sessions']);
73
86
 
74
87
  // B6 (plan-chat-context-window-display Phase B): context-window caps per
75
88
  // model. Mirrors CONTEXT_CAPS from @olam/intelligence/src/llm-router/providers/claude.ts.
@@ -275,6 +288,12 @@ export function createHandler({
275
288
  * or inspect the idempotency store. Production callers omit this.
276
289
  */
277
290
  _promoteDedupe: promoteDedupe = _promoteDedupe,
291
+ /** multi-turn-cloud-sandbox-dispatch Phase A3 — optional override for tests.
292
+ * When supplied, replaces the default plan-DO forward path so tests can
293
+ * inject a stub returning a fake sandbox_session_id without exercising the
294
+ * real cloud-dispatch path. Production callers omit; A6 wires the real
295
+ * forward. */
296
+ dispatchTurnForward,
278
297
  }) {
279
298
  if (!pool) throw new Error('createHandler: { pool } required');
280
299
  if (typeof bearer !== 'string' || bearer.length === 0) {
@@ -297,6 +316,90 @@ export function createHandler({
297
316
  // through ids at line-rate without the bucket).
298
317
  const resolveLimiter = rateLimiter ?? createRateLimiter({ capacity: 60, windowMs: 60_000 });
299
318
 
319
+ /**
320
+ * Default plan-DO forward — used by handlePostDispatchTurn when no
321
+ * test-injected stub is provided.
322
+ *
323
+ * Phase B follow-up (operator E2E directive 2026-05-27: full SPA→plan-DO→CF
324
+ * chain test): when OLAM_CLOUD_URL + OLAM_SHOWCASE_PASSWORD are set, this
325
+ * function POSTs the turn payload to plan-DO at
326
+ * `${OLAM_CLOUD_URL}/v1/dispatch?plan_id=<session_id>` with showcase Basic
327
+ * auth (mirrors server.mjs /api/cloud-dispatch's existing forward shape).
328
+ * Plan-DO then spawns the CF Sandbox container; chunks flow back to host-cp
329
+ * via the cloudflared tunnel + the in-container chunk-poster pattern
330
+ * (PR #1165 substrate).
331
+ *
332
+ * When the envs are absent (typical for unit-test setups), the function
333
+ * throws — operators MUST set both envs to enable real CF dispatch. The
334
+ * existing `dispatchTurnForward` opt remains available for tests that
335
+ * want to inject a stub returning a fake sandbox_session_id.
336
+ *
337
+ * @param {object} ctx
338
+ * @param {{ session_id: string, world_id: string|null, actor_id: string }} ctx.session
339
+ * @param {string} ctx.turn_id
340
+ * @param {string} ctx.prompt
341
+ * @param {string} ctx.conversation_preamble — Phase B fills this; empty in Phase A
342
+ * @returns {Promise<{ sandbox_session_id: string | null }>}
343
+ */
344
+ async function defaultDispatchTurnForward(ctx) {
345
+ const cloudUrl = process.env.OLAM_CLOUD_URL;
346
+ const showcasePw = process.env.OLAM_SHOWCASE_PASSWORD;
347
+ if (!cloudUrl || !showcasePw) {
348
+ throw new Error(
349
+ 'dispatch-turn forward not wired: set OLAM_CLOUD_URL + OLAM_SHOWCASE_PASSWORD ' +
350
+ 'env vars (see /api/cloud-dispatch pattern in server.mjs) OR inject ' +
351
+ '`dispatchTurnForward` via createHandler() opts for test stubs.',
352
+ );
353
+ }
354
+ const planId = ctx.session.session_id;
355
+ const basicAuth = Buffer.from(`operator:${showcasePw}`).toString('base64');
356
+ // Mirror /api/cloud-dispatch's enriched body: include the operator's
357
+ // anthropicBaseUrl (so spawned CF Sandbox child worlds can reach the
358
+ // auth-Worker), the operator's repoUrl (for the in-container clone),
359
+ // plus the new multi-turn fields (turn_id, conversation_preamble).
360
+ // anthropicBaseUrl + repoUrl are read from ~/.olam/* by the existing
361
+ // host-cp pattern; here we just forward whatever the SPA passed (the
362
+ // SPA may have included them) — and rely on plan-DO's own enrichment
363
+ // for absent fields.
364
+ const body = JSON.stringify({
365
+ world_id: ctx.session.world_id,
366
+ session_id: ctx.session.session_id,
367
+ actor_id: ctx.session.actor_id,
368
+ turn_id: ctx.turn_id,
369
+ prompt: ctx.prompt,
370
+ conversation_preamble: ctx.conversation_preamble ?? '',
371
+ });
372
+ const upstream = await fetch(
373
+ `${cloudUrl.replace(/\/+$/, '')}/v1/dispatch?plan_id=${encodeURIComponent(planId)}`,
374
+ {
375
+ method: 'POST',
376
+ headers: {
377
+ 'Authorization': `Basic ${basicAuth}`,
378
+ 'content-type': 'application/json',
379
+ },
380
+ body,
381
+ },
382
+ );
383
+ if (!upstream.ok) {
384
+ const errBody = await upstream.text().catch(() => '');
385
+ throw new Error(
386
+ `plan-DO /v1/dispatch returned ${upstream.status}: ${errBody.slice(0, 200)}`,
387
+ );
388
+ }
389
+ // plan-DO returns { sandbox_session_id, ... } on success per PR #1165
390
+ // (see packages/plan-agent-do/src/dispatch-sandbox-agent.ts). We pluck
391
+ // sandbox_session_id for the SPA's session-thread render; everything
392
+ // else flows back via the in-container chunk-poster → host-cp
393
+ // /api/plan-chat/v1/chunks path (NOT through this return).
394
+ let parsed;
395
+ try {
396
+ parsed = await upstream.json();
397
+ } catch {
398
+ parsed = {};
399
+ }
400
+ return { sandbox_session_id: parsed.sandbox_session_id ?? null };
401
+ }
402
+
300
403
  function checkAuth(req) {
301
404
  const header = req.headers.authorization;
302
405
  if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
@@ -545,12 +648,23 @@ export function createHandler({
545
648
  'world_id query param required (alphanumerics + _ - . only)',
546
649
  );
547
650
  }
548
- if (!sessionId || !SCOPE_ID_RE.test(sessionId)) {
651
+ // Phase C C-S1 — planning_sessions shapes are scoped by world_id only
652
+ // (no session_id; the hook fetches all dispatch sessions for a world).
653
+ // All other tables require session_id for the (session_id, world_id)
654
+ // predicate that guards per-session Electric cache isolation.
655
+ const requiresSessionId = tableParam !== 'planning_sessions';
656
+ if (requiresSessionId && (!sessionId || !SCOPE_ID_RE.test(sessionId))) {
549
657
  return badRequest(
550
658
  res,
551
659
  'session_id query param required (alphanumerics + _ - . only)',
552
660
  );
553
661
  }
662
+ if (sessionId && !SCOPE_ID_RE.test(sessionId)) {
663
+ return badRequest(
664
+ res,
665
+ 'session_id query param must match alphanumerics + _ - . only',
666
+ );
667
+ }
554
668
 
555
669
  // Forward all client query params EXCEPT the scoped set. Client-supplied
556
670
  // `where` is silently dropped — server-derived `where` always wins.
@@ -566,14 +680,14 @@ export function createHandler({
566
680
  // PB2 — server-derived `where` clause; safe because SCOPE_ID_RE has
567
681
  // already rejected single-quotes, semicolons, and SQL meta-characters.
568
682
  //
569
- // Predicate order: session_id FIRST. Electric SQL caches shapes by the
570
- // exact predicate string; flipping the original (world_id, session_id)
571
- // order forces a fresh shape and avoids a known cache-staleness bug
572
- // where an empty-result shape persists after new rows land.
573
- upstream.searchParams.set(
574
- 'where',
575
- `session_id='${sessionId}' AND world_id='${worldId}'`,
576
- );
683
+ // planning_sessions: world_id only (no per-session scope needed).
684
+ // All other tables: session_id FIRST per Electric cache-isolation rule
685
+ // (flipping order forces a fresh shape and avoids a known cache-staleness
686
+ // bug where an empty-result shape persists after new rows land).
687
+ const whereClause = requiresSessionId
688
+ ? `session_id='${sessionId}' AND world_id='${worldId}'`
689
+ : `world_id='${worldId}'`;
690
+ upstream.searchParams.set('where', whereClause);
577
691
 
578
692
  // Phase A A2 — log BEFORE upstream fetch. Includes the rewritten
579
693
  // `where` predicate so an operator can correlate client-supplied
@@ -682,6 +796,302 @@ export function createHandler({
682
796
  }
683
797
  }
684
798
 
799
+ /**
800
+ * POST /v1/sessions/create — multi-turn dispatch session bootstrap
801
+ * (multi-turn-cloud-sandbox-dispatch Phase A2).
802
+ *
803
+ * Body shape:
804
+ * {
805
+ * world_id: string, // required — target dispatch world
806
+ * budget_usd_cap?: number | null, // optional — per-session budget cap; null = uncapped
807
+ * allow_unpriced_models?: boolean // optional — opt session into the
808
+ * // pricingForModel-returns-null fallback
809
+ * // (Plan A T11); default false (refuse unknown
810
+ * // models with 502 at /v1/dispatch-turn time).
811
+ * }
812
+ *
813
+ * Returns 200 { session_id: uuid }.
814
+ *
815
+ * Auth: plan-chat-secret Bearer (same gate as /v1/chunks + /v1/shape).
816
+ * actor_id is derived from the bearer principal (single-tenant v1; multi-tenant
817
+ * scoping is the cloud-spa-tenant-scoping plan's surface, NOT this endpoint's).
818
+ */
819
+ async function handlePostSessionsCreate(req, res) {
820
+ if (!checkAuth(req)) return unauthorized(res);
821
+ let body;
822
+ try {
823
+ body = await readJson(req);
824
+ } catch (err) {
825
+ return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
826
+ }
827
+ if (!body || typeof body.world_id !== 'string' || body.world_id.length === 0) {
828
+ return badRequest(res, 'world_id required');
829
+ }
830
+ const principal = principalFromBearer(bearer, body);
831
+ const budgetUsdCap =
832
+ typeof body.budget_usd_cap === 'number' && Number.isFinite(body.budget_usd_cap)
833
+ ? body.budget_usd_cap
834
+ : null;
835
+ const allowUnpricedModels = body.allow_unpriced_models === true;
836
+ // A6 (Decision 9 always-on threading): host-cp's /api/cloud-dispatch
837
+ // pre-creates the planning_sessions row using the dispatch's session_id.
838
+ // The SPA may also call /v1/sessions/create directly; ON CONFLICT
839
+ // DO NOTHING in createDispatchSession handles both shapes.
840
+ const providedSessionId =
841
+ typeof body.session_id === 'string' && body.session_id.length > 0
842
+ ? body.session_id
843
+ : null;
844
+ try {
845
+ const result = await createDispatchSession({
846
+ pool,
847
+ actorId: principal.actorId,
848
+ worldId: body.world_id,
849
+ budgetUsdCap,
850
+ allowUnpricedModels,
851
+ sessionId: providedSessionId,
852
+ });
853
+ return send(res, 200, result);
854
+ } catch (err) {
855
+ return send(res, 500, {
856
+ error: 'create-failed',
857
+ message: String(err?.message ?? err),
858
+ });
859
+ }
860
+ }
861
+
862
+ /**
863
+ * POST /v1/dispatch-turn — multi-turn dispatch turn entry-point
864
+ * (multi-turn-cloud-sandbox-dispatch Phase A3 — load-bearing seam).
865
+ *
866
+ * Body shape:
867
+ * {
868
+ * session_id: string, // required — existing dispatch session
869
+ * prompt: string, // required — operator's prompt for this turn
870
+ * }
871
+ *
872
+ * Returns:
873
+ * - 200 { turn_id, sandbox_session_id } — turn dispatched; agent runtime
874
+ * streams chunks to /v1/chunks under (world_id, session_id).
875
+ * - 401 — bearer missing/wrong.
876
+ * - 400 — body invalid (missing session_id / prompt).
877
+ * - 404 — session not found OR not owned by caller (ownership check via actor_id).
878
+ * - 409 — in_flight_turn_id lock already held (concurrent dispatch).
879
+ * - 402 — budget_usd_cap exhausted; clear cap or halt to retry.
880
+ * - 502 — Phase D unknown-model guard fires (model pricing returned null
881
+ * AND session has allow_unpriced_models=false).
882
+ *
883
+ * Phase A3 SCOPE (this commit):
884
+ * - Atomic lock claim via UPDATE...WHERE in_flight_turn_id IS NULL RETURNING.
885
+ * - Budget cap check via getDispatchSession.total_usd vs budget_usd_cap.
886
+ * - Preamble construction STUBBED (Phase B fills the prior-conversation envelope).
887
+ * - plan-DO forward STUBBED via injectable `dispatchTurnForward` callback
888
+ * (test stubs return a fake sandbox_session_id; production wires to plan-DO).
889
+ * - 502 unknown-model guard STUBBED (Phase D wires real pricingForModel check).
890
+ *
891
+ * Phase B-D layers on:
892
+ * - Phase B: replace preamble stub with `<prior-conversation>` envelope
893
+ * constructed from chunks (host-cp owns rehydration per Decision 6).
894
+ * - Phase D: replace 502 stub with real pricingForModel fail-loud guard.
895
+ * - Phase D: implement total_usd atomic UPDATE on chunk-land + stale-lock cron.
896
+ */
897
+ async function handlePostDispatchTurn(req, res) {
898
+ if (!checkAuth(req)) return unauthorized(res);
899
+ let body;
900
+ try {
901
+ body = await readJson(req);
902
+ } catch (err) {
903
+ return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
904
+ }
905
+ if (!body || typeof body.session_id !== 'string' || body.session_id.length === 0) {
906
+ return badRequest(res, 'session_id required');
907
+ }
908
+ if (typeof body.prompt !== 'string' || body.prompt.length === 0) {
909
+ return badRequest(res, 'prompt required');
910
+ }
911
+ const principal = principalFromBearer(bearer, body);
912
+
913
+ // Ownership check: session must exist AND belong to caller.
914
+ let session;
915
+ try {
916
+ session = await getDispatchSession({
917
+ pool,
918
+ sessionId: body.session_id,
919
+ actorId: principal.actorId,
920
+ });
921
+ } catch (err) {
922
+ return send(res, 500, {
923
+ error: 'session-lookup-failed',
924
+ message: String(err?.message ?? err),
925
+ });
926
+ }
927
+ if (!session) {
928
+ return send(res, 404, { error: 'session_not_found' });
929
+ }
930
+
931
+ // Halted check (A4) — operator's "block next turn" state takes precedence
932
+ // over budget + lock checks because it's an explicit operator pause. The
933
+ // running container is NOT signalled (T13); only future turns are blocked.
934
+ if (session.halted_at) {
935
+ return send(res, 409, {
936
+ error: 'session_halted',
937
+ session_id: body.session_id,
938
+ halted_at: session.halted_at,
939
+ });
940
+ }
941
+
942
+ // Budget cap check (Phase D layers on the unknown-model guard).
943
+ if (
944
+ session.budget_usd_cap !== null &&
945
+ Number.isFinite(session.budget_usd_cap) &&
946
+ session.total_usd >= session.budget_usd_cap
947
+ ) {
948
+ return send(res, 402, {
949
+ error: 'budget_exhausted',
950
+ total_usd: session.total_usd,
951
+ budget_usd_cap: session.budget_usd_cap,
952
+ });
953
+ }
954
+
955
+ // Atomic lock claim — second concurrent attempt sees empty result.
956
+ const turnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
957
+ let claimed;
958
+ try {
959
+ claimed = await claimDispatchTurnLock({
960
+ pool,
961
+ sessionId: body.session_id,
962
+ turnId,
963
+ });
964
+ } catch (err) {
965
+ return send(res, 500, {
966
+ error: 'lock-claim-failed',
967
+ message: String(err?.message ?? err),
968
+ });
969
+ }
970
+ if (!claimed) {
971
+ return send(res, 409, { error: 'in_flight', session_id: body.session_id });
972
+ }
973
+
974
+ // Forward to plan-DO (stubbed via injected callback for tests; production
975
+ // implementation lands when /api/cloud-dispatch is refactored to share the
976
+ // forward path — A6 work).
977
+ try {
978
+ const forward = dispatchTurnForward ?? defaultDispatchTurnForward;
979
+ const result = await forward({
980
+ session: {
981
+ session_id: session.session_id,
982
+ world_id: session.world_id,
983
+ actor_id: session.actor_id,
984
+ },
985
+ turn_id: turnId,
986
+ prompt: body.prompt,
987
+ // Phase B fills preamble with conversation history from chunks.
988
+ conversation_preamble: '',
989
+ });
990
+ return send(res, 200, {
991
+ turn_id: turnId,
992
+ sandbox_session_id: result.sandbox_session_id ?? null,
993
+ });
994
+ } catch (err) {
995
+ // Forward failed — clear the lock so the operator can retry.
996
+ try {
997
+ await clearDispatchTurnLock({ pool, sessionId: body.session_id });
998
+ } catch { /* logged separately */ }
999
+ return send(res, 502, {
1000
+ error: 'dispatch_forward_failed',
1001
+ message: String(err?.message ?? err),
1002
+ });
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * GET /v1/sessions — list multi-turn dispatch sessions for the caller
1008
+ * (multi-turn-cloud-sandbox-dispatch Phase A5).
1009
+ *
1010
+ * Scoped to actor_id (single-tenant ownership isolation; cloud-spa-tenant-scoping
1011
+ * adds Neon RLS as defense-in-depth). Returns dispatch sessions only
1012
+ * (session_type='dispatch'), excluding archived rows, ordered by last_turn_at
1013
+ * DESC (most recently active first) then created_at DESC as tiebreaker.
1014
+ *
1015
+ * Query params:
1016
+ * ?limit=<N> — capped at 200; default 50.
1017
+ *
1018
+ * Response: 200 { sessions: [...] }
1019
+ *
1020
+ * Auth: plan-chat-secret Bearer. NO body (GET).
1021
+ */
1022
+ async function handleGetSessions(req, res, url) {
1023
+ if (!checkAuth(req)) return unauthorized(res);
1024
+ const principal = principalFromBearer(bearer, null);
1025
+ const limitParam = url.searchParams.get('limit');
1026
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 200) : 50;
1027
+ try {
1028
+ const sessions = await listDispatchSessions({ pool, actorId: principal.actorId, limit });
1029
+ return send(res, 200, { sessions });
1030
+ } catch (err) {
1031
+ return send(res, 500, {
1032
+ error: 'list-failed',
1033
+ message: String(err?.message ?? err),
1034
+ });
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * POST /v1/sessions/:id/halt — operator-driven "block next turn"
1040
+ * (multi-turn-cloud-sandbox-dispatch Phase A4 + T13).
1041
+ *
1042
+ * Clears in_flight_turn_id (releases the lock if held) AND sets halted_at.
1043
+ * Future /v1/dispatch-turn calls return 409 'session_halted' until
1044
+ * /v1/sessions/:id/reactivate clears halted_at.
1045
+ *
1046
+ * Does NOT stop an in-flight container. The running container completes
1047
+ * its current turn naturally; only NEXT turns are blocked. UX must label
1048
+ * the halt button "Block next turn" (Plan A Phase C C6).
1049
+ *
1050
+ * Auth: plan-chat-secret Bearer. Ownership via actor_id.
1051
+ * Returns 200 { halted: true } | 404 session_not_found | 401 | 500.
1052
+ */
1053
+ async function handlePostSessionHalt(req, res, sessionId) {
1054
+ if (!checkAuth(req)) return unauthorized(res);
1055
+ const principal = principalFromBearer(bearer, null);
1056
+ try {
1057
+ const halted = await haltDispatchSession({
1058
+ pool,
1059
+ sessionId,
1060
+ actorId: principal.actorId,
1061
+ });
1062
+ if (!halted) return send(res, 404, { error: 'session_not_found' });
1063
+ return send(res, 200, { halted: true, session_id: sessionId });
1064
+ } catch (err) {
1065
+ return send(res, 500, {
1066
+ error: 'halt-failed',
1067
+ message: String(err?.message ?? err),
1068
+ });
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * POST /v1/sessions/:id/reactivate — inverse of halt; clears halted_at
1074
+ * so subsequent dispatch turns can proceed. Idempotent.
1075
+ */
1076
+ async function handlePostSessionReactivate(req, res, sessionId) {
1077
+ if (!checkAuth(req)) return unauthorized(res);
1078
+ const principal = principalFromBearer(bearer, null);
1079
+ try {
1080
+ const reactivated = await reactivateDispatchSession({
1081
+ pool,
1082
+ sessionId,
1083
+ actorId: principal.actorId,
1084
+ });
1085
+ if (!reactivated) return send(res, 404, { error: 'session_not_found' });
1086
+ return send(res, 200, { reactivated: true, session_id: sessionId });
1087
+ } catch (err) {
1088
+ return send(res, 500, {
1089
+ error: 'reactivate-failed',
1090
+ message: String(err?.message ?? err),
1091
+ });
1092
+ }
1093
+ }
1094
+
685
1095
  async function handleGetPlanningSessions(req, res, url) {
686
1096
  if (!checkAuth(req)) return unauthorized(res);
687
1097
  // Derive actorId from the bearer principal (no body on GET).
@@ -1039,12 +1449,56 @@ export function createHandler({
1039
1449
  return send(res, 202, { worldId, promoteId, specPath, status: 'dispatched' });
1040
1450
  }
1041
1451
 
1452
+ // multi-turn-cloud-sandbox-dispatch A7 follow-up (operator E2E directive
1453
+ // 2026-05-27): the SPA at http://127.0.0.1:19000 talks to plan-chat-service
1454
+ // at http://127.0.0.1:3200 — different origins → browser preflight required.
1455
+ // Allow-list controlled via OLAM_PLAN_CHAT_CORS_ORIGINS env (comma-separated).
1456
+ // Default includes the local SPA dev port; production deployments override.
1457
+ // Surfaced by the webwright spec which the .mjs Playwright spec did NOT
1458
+ // catch (page.evaluate fetch ≠ server-to-server fetch).
1459
+ const corsOrigins = (
1460
+ process.env.OLAM_PLAN_CHAT_CORS_ORIGINS ?? 'http://127.0.0.1:19000,http://localhost:19000'
1461
+ ).split(',').map((o) => o.trim()).filter(Boolean);
1462
+
1463
+ function applyCorsHeaders(req, res) {
1464
+ const origin = req.headers.origin;
1465
+ if (typeof origin === 'string' && corsOrigins.includes(origin)) {
1466
+ res.setHeader('access-control-allow-origin', origin);
1467
+ res.setHeader('vary', 'origin');
1468
+ res.setHeader('access-control-allow-credentials', 'true');
1469
+ res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
1470
+ res.setHeader('access-control-allow-headers', 'authorization, content-type');
1471
+ res.setHeader('access-control-max-age', '600');
1472
+ }
1473
+ }
1474
+
1042
1475
  return async function handler(req, res) {
1043
1476
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
1477
+ applyCorsHeaders(req, res);
1478
+ // CORS preflight — short-circuit OPTIONS for allow-listed origins.
1479
+ if (req.method === 'OPTIONS') {
1480
+ const origin = req.headers.origin;
1481
+ if (typeof origin === 'string' && corsOrigins.includes(origin)) {
1482
+ res.statusCode = 204;
1483
+ return res.end();
1484
+ }
1485
+ // Not allowlisted — fall through to 404 (default not-found shape).
1486
+ }
1044
1487
  if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
1045
1488
  if (req.method === 'POST' && url.pathname === '/v1/chunks') return handlePostChunks(req, res);
1046
1489
  if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
1047
1490
  if (req.method === 'GET' && url.pathname === '/v1/planning-sessions') return handleGetPlanningSessions(req, res, url);
1491
+ if (req.method === 'POST' && url.pathname === '/v1/sessions/create') return handlePostSessionsCreate(req, res);
1492
+ if (req.method === 'GET' && url.pathname === '/v1/sessions') return handleGetSessions(req, res, url);
1493
+ if (req.method === 'POST' && url.pathname === '/v1/dispatch-turn') return handlePostDispatchTurn(req, res);
1494
+ const haltMatch = /^\/v1\/sessions\/([^/]+)\/halt$/.exec(url.pathname);
1495
+ if (haltMatch && req.method === 'POST') {
1496
+ return handlePostSessionHalt(req, res, decodeURIComponent(haltMatch[1]));
1497
+ }
1498
+ const reactivateMatch = /^\/v1\/sessions\/([^/]+)\/reactivate$/.exec(url.pathname);
1499
+ if (reactivateMatch && req.method === 'POST') {
1500
+ return handlePostSessionReactivate(req, res, decodeURIComponent(reactivateMatch[1]));
1501
+ }
1048
1502
  if (req.method === 'POST' && url.pathname === '/v1/crystallize') return handlePostCrystallize(req, res);
1049
1503
  // Phase A A1 — /v1/resolve/:id (plan-chat-spa-supersedes-control-plane).
1050
1504
  const resolveMatch = /^\/v1\/resolve\/([^/]+)$/.exec(url.pathname);
@@ -1095,6 +1549,33 @@ export async function startService(opts = {}) {
1095
1549
  const bearer = opts.bearer ?? ensureSecret(secretPath);
1096
1550
 
1097
1551
  const pool = opts.pool ?? new pg.Pool({ connectionString: databaseUrl, max: 8 });
1552
+
1553
+ // multi-turn-cloud-sandbox-dispatch A7 follow-up (operator E2E directive
1554
+ // 2026-05-27): apply SCHEMA_SQL on boot so a fresh local dev stack picks
1555
+ // up A1+A4 migrations (planning_sessions session_type / world_id /
1556
+ // halted_at / etc.) automatically. Skipped when opts.pool was supplied
1557
+ // (test pool stubs don't run real SQL); skipped when OLAM_PLAN_CHAT_SKIP_SCHEMA
1558
+ // is set (operator escape hatch for managed-DB environments where the
1559
+ // app shouldn't apply DDL). Webwright E2E surfaced this gap: the API-only
1560
+ // tests pass against a stubbed pool, but a real-Neon dev stack hit
1561
+ // `column "session_type" of relation "planning_sessions" does not exist`
1562
+ // until SCHEMA_SQL was applied manually.
1563
+ if (!opts.pool && process.env.OLAM_PLAN_CHAT_SKIP_SCHEMA !== '1') {
1564
+ try {
1565
+ await pool.query(SCHEMA_SQL);
1566
+ // eslint-disable-next-line no-console
1567
+ console.error('[plan-chat-service] SCHEMA_SQL applied on boot');
1568
+ } catch (schemaErr) {
1569
+ // eslint-disable-next-line no-console
1570
+ console.error(
1571
+ `[plan-chat-service] SCHEMA_SQL apply FAILED on boot: ${schemaErr?.message ?? schemaErr}`,
1572
+ );
1573
+ // Don't crash the service — schema may already be applied externally
1574
+ // (e.g. CI managed DB). Fail-soft + log; the operator's first POST
1575
+ // will surface the column-missing error if the schema really is stale.
1576
+ }
1577
+ }
1578
+
1098
1579
  const handler = createHandler({
1099
1580
  pool,
1100
1581
  bearer,
@@ -1107,6 +1588,7 @@ export async function startService(opts = {}) {
1107
1588
  resolveActor: opts.resolveActor,
1108
1589
  rateLimiter: opts.rateLimiter,
1109
1590
  _promoteDedupe: opts._promoteDedupe,
1591
+ dispatchTurnForward: opts.dispatchTurnForward,
1110
1592
  });
1111
1593
  const server = http.createServer((req, res) => {
1112
1594
  handler(req, res).catch((err) => {