@pleri/olam-cli 0.1.186 → 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 (189) hide show
  1. package/README.md +1 -1
  2. package/dist/ask/knowledge-pack-builder.d.ts.map +1 -1
  3. package/dist/ask/knowledge-pack-builder.js +5 -0
  4. package/dist/ask/knowledge-pack-builder.js.map +1 -1
  5. package/dist/ask/knowledge-pack.generated.d.ts.map +1 -1
  6. package/dist/ask/knowledge-pack.generated.js +442 -33
  7. package/dist/ask/knowledge-pack.generated.js.map +1 -1
  8. package/dist/commands/auth-status.js +2 -2
  9. package/dist/commands/auth-status.js.map +1 -1
  10. package/dist/commands/auth.js +1 -1
  11. package/dist/commands/auth.js.map +1 -1
  12. package/dist/commands/bootstrap.d.ts +4 -0
  13. package/dist/commands/bootstrap.d.ts.map +1 -1
  14. package/dist/commands/bootstrap.js +6 -9
  15. package/dist/commands/bootstrap.js.map +1 -1
  16. package/dist/commands/clean.js +1 -1
  17. package/dist/commands/clean.js.map +1 -1
  18. package/dist/commands/completion.d.ts.map +1 -1
  19. package/dist/commands/completion.js +1 -4
  20. package/dist/commands/completion.js.map +1 -1
  21. package/dist/commands/create.d.ts.map +1 -1
  22. package/dist/commands/create.js +10 -0
  23. package/dist/commands/create.js.map +1 -1
  24. package/dist/commands/crystallize.js +12 -14
  25. package/dist/commands/crystallize.js.map +1 -1
  26. package/dist/commands/destroy.d.ts +13 -1
  27. package/dist/commands/destroy.d.ts.map +1 -1
  28. package/dist/commands/destroy.js +52 -6
  29. package/dist/commands/destroy.js.map +1 -1
  30. package/dist/commands/dispatch.d.ts +9 -0
  31. package/dist/commands/dispatch.d.ts.map +1 -1
  32. package/dist/commands/dispatch.js +21 -2
  33. package/dist/commands/dispatch.js.map +1 -1
  34. package/dist/commands/doctor.d.ts +1 -1
  35. package/dist/commands/doctor.d.ts.map +1 -1
  36. package/dist/commands/doctor.js +29 -22
  37. package/dist/commands/doctor.js.map +1 -1
  38. package/dist/commands/enter.d.ts +3 -3
  39. package/dist/commands/enter.d.ts.map +1 -1
  40. package/dist/commands/enter.js +57 -44
  41. package/dist/commands/enter.js.map +1 -1
  42. package/dist/commands/flywheel/index.d.ts.map +1 -1
  43. package/dist/commands/flywheel/index.js +1 -1
  44. package/dist/commands/flywheel/index.js.map +1 -1
  45. package/dist/commands/host-cp.d.ts.map +1 -1
  46. package/dist/commands/host-cp.js +2 -1
  47. package/dist/commands/host-cp.js.map +1 -1
  48. package/dist/commands/implode.d.ts.map +1 -1
  49. package/dist/commands/implode.js +1 -1
  50. package/dist/commands/implode.js.map +1 -1
  51. package/dist/commands/init.d.ts +20 -0
  52. package/dist/commands/init.d.ts.map +1 -1
  53. package/dist/commands/init.js +102 -9
  54. package/dist/commands/init.js.map +1 -1
  55. package/dist/commands/install.js +2 -2
  56. package/dist/commands/install.js.map +1 -1
  57. package/dist/commands/kg-build.d.ts.map +1 -1
  58. package/dist/commands/kg-build.js +3 -0
  59. package/dist/commands/kg-build.js.map +1 -1
  60. package/dist/commands/kg-classify.d.ts +20 -0
  61. package/dist/commands/kg-classify.d.ts.map +1 -1
  62. package/dist/commands/kg-classify.js +59 -42
  63. package/dist/commands/kg-classify.js.map +1 -1
  64. package/dist/commands/kg-mirror.d.ts +40 -0
  65. package/dist/commands/kg-mirror.d.ts.map +1 -0
  66. package/dist/commands/kg-mirror.js +228 -0
  67. package/dist/commands/kg-mirror.js.map +1 -0
  68. package/dist/commands/mcp/index.js +1 -1
  69. package/dist/commands/mcp/index.js.map +1 -1
  70. package/dist/commands/memory/index.d.ts.map +1 -1
  71. package/dist/commands/memory/index.js +1 -1
  72. package/dist/commands/memory/index.js.map +1 -1
  73. package/dist/commands/resume.d.ts.map +1 -1
  74. package/dist/commands/resume.js +1 -1
  75. package/dist/commands/resume.js.map +1 -1
  76. package/dist/commands/services-tls.d.ts +120 -0
  77. package/dist/commands/services-tls.d.ts.map +1 -0
  78. package/dist/commands/services-tls.js +434 -0
  79. package/dist/commands/services-tls.js.map +1 -0
  80. package/dist/commands/services.d.ts.map +1 -1
  81. package/dist/commands/services.js +40 -1
  82. package/dist/commands/services.js.map +1 -1
  83. package/dist/commands/setup-linux-gate.d.ts.map +1 -1
  84. package/dist/commands/setup-linux-gate.js +1 -3
  85. package/dist/commands/setup-linux-gate.js.map +1 -1
  86. package/dist/commands/setup-metrics.d.ts.map +1 -1
  87. package/dist/commands/setup-metrics.js +1 -2
  88. package/dist/commands/setup-metrics.js.map +1 -1
  89. package/dist/commands/setup-phase-5a-skill-source.d.ts +17 -1
  90. package/dist/commands/setup-phase-5a-skill-source.d.ts.map +1 -1
  91. package/dist/commands/setup-phase-5a-skill-source.js +69 -6
  92. package/dist/commands/setup-phase-5a-skill-source.js.map +1 -1
  93. package/dist/commands/setup.d.ts +26 -1
  94. package/dist/commands/setup.d.ts.map +1 -1
  95. package/dist/commands/setup.js +189 -47
  96. package/dist/commands/setup.js.map +1 -1
  97. package/dist/commands/skills-onboard.d.ts.map +1 -1
  98. package/dist/commands/skills-onboard.js +4 -1
  99. package/dist/commands/skills-onboard.js.map +1 -1
  100. package/dist/commands/skills-source.d.ts.map +1 -1
  101. package/dist/commands/skills-source.js +20 -4
  102. package/dist/commands/skills-source.js.map +1 -1
  103. package/dist/commands/status.d.ts.map +1 -1
  104. package/dist/commands/status.js +5 -1
  105. package/dist/commands/status.js.map +1 -1
  106. package/dist/commands/upgrade.d.ts.map +1 -1
  107. package/dist/commands/upgrade.js +1 -3
  108. package/dist/commands/upgrade.js.map +1 -1
  109. package/dist/commands/yolo.d.ts.map +1 -1
  110. package/dist/commands/yolo.js +1 -1
  111. package/dist/commands/yolo.js.map +1 -1
  112. package/dist/context.d.ts +4 -0
  113. package/dist/context.d.ts.map +1 -1
  114. package/dist/context.js +3 -2
  115. package/dist/context.js.map +1 -1
  116. package/dist/image-digests.json +8 -8
  117. package/dist/index.js +4409 -2375
  118. package/dist/index.js.map +1 -1
  119. package/dist/lib/auth-refresh-kubernetes.d.ts.map +1 -1
  120. package/dist/lib/auth-refresh-kubernetes.js +14 -5
  121. package/dist/lib/auth-refresh-kubernetes.js.map +1 -1
  122. package/dist/lib/bootstrap-kubernetes.d.ts +41 -0
  123. package/dist/lib/bootstrap-kubernetes.d.ts.map +1 -1
  124. package/dist/lib/bootstrap-kubernetes.js +289 -36
  125. package/dist/lib/bootstrap-kubernetes.js.map +1 -1
  126. package/dist/lib/cf-access-token.d.ts.map +1 -1
  127. package/dist/lib/cf-access-token.js +2 -3
  128. package/dist/lib/cf-access-token.js.map +1 -1
  129. package/dist/lib/health-probes.d.ts +14 -0
  130. package/dist/lib/health-probes.d.ts.map +1 -1
  131. package/dist/lib/health-probes.js +41 -3
  132. package/dist/lib/health-probes.js.map +1 -1
  133. package/dist/lib/help-groups.d.ts +36 -0
  134. package/dist/lib/help-groups.d.ts.map +1 -0
  135. package/dist/lib/help-groups.js +124 -0
  136. package/dist/lib/help-groups.js.map +1 -0
  137. package/dist/lib/k8s-bootstrap.d.ts +6 -0
  138. package/dist/lib/k8s-bootstrap.d.ts.map +1 -1
  139. package/dist/lib/k8s-bootstrap.js +15 -2
  140. package/dist/lib/k8s-bootstrap.js.map +1 -1
  141. package/dist/lib/k8s-secret-render.d.ts.map +1 -1
  142. package/dist/lib/k8s-secret-render.js +17 -10
  143. package/dist/lib/k8s-secret-render.js.map +1 -1
  144. package/dist/lib/memory-secret.d.ts +15 -2
  145. package/dist/lib/memory-secret.d.ts.map +1 -1
  146. package/dist/lib/memory-secret.js +25 -8
  147. package/dist/lib/memory-secret.js.map +1 -1
  148. package/dist/lib/upgrade-check.d.ts +60 -0
  149. package/dist/lib/upgrade-check.d.ts.map +1 -0
  150. package/dist/lib/upgrade-check.js +169 -0
  151. package/dist/lib/upgrade-check.js.map +1 -0
  152. package/dist/lib/upgrade-kubernetes.d.ts +17 -0
  153. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  154. package/dist/lib/upgrade-kubernetes.js +125 -1
  155. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  156. package/dist/mcp-server.js +2687 -2818
  157. package/hermes-bundle/version.json +1 -1
  158. package/host-cp/k8s/manifests/30-configmap.yaml +8 -1
  159. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  160. package/host-cp/k8s/manifests/60-service.yaml +12 -4
  161. package/host-cp/k8s/manifests/70-ingressroute.yaml +58 -0
  162. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  163. package/host-cp/k8s/manifests/chunks-electric/10-serviceaccount.yaml +8 -0
  164. package/host-cp/k8s/manifests/chunks-electric/20-rbac.yaml +27 -0
  165. package/host-cp/k8s/manifests/chunks-electric/30-configmap.yaml +23 -0
  166. package/host-cp/k8s/manifests/chunks-electric/45-pvc.yaml +19 -0
  167. package/host-cp/k8s/manifests/chunks-electric/50-deployment.yaml +84 -0
  168. package/host-cp/k8s/manifests/chunks-electric/60-service.yaml +17 -0
  169. package/host-cp/k8s/manifests/chunks-postgres/10-serviceaccount.yaml +8 -0
  170. package/host-cp/k8s/manifests/chunks-postgres/20-rbac.yaml +29 -0
  171. package/host-cp/k8s/manifests/chunks-postgres/30-configmap.yaml +185 -0
  172. package/host-cp/k8s/manifests/chunks-postgres/45-pvc.yaml +24 -0
  173. package/host-cp/k8s/manifests/chunks-postgres/50-deployment.yaml +101 -0
  174. package/host-cp/k8s/manifests/chunks-postgres/60-service.yaml +24 -0
  175. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  176. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  177. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  178. package/host-cp/k8s/manifests/plan-chat-service/10-serviceaccount.yaml +8 -0
  179. package/host-cp/k8s/manifests/plan-chat-service/20-rbac.yaml +29 -0
  180. package/host-cp/k8s/manifests/plan-chat-service/30-configmap.yaml +36 -0
  181. package/host-cp/k8s/manifests/plan-chat-service/45-pvc.yaml +24 -0
  182. package/host-cp/k8s/manifests/plan-chat-service/50-deployment.yaml +135 -0
  183. package/host-cp/k8s/manifests/plan-chat-service/60-service.yaml +17 -0
  184. package/host-cp/src/plan-chat-secret.mjs +16 -1
  185. package/host-cp/src/plan-chat-service.mjs +709 -11
  186. package/host-cp/src/planning-sessions.mjs +252 -0
  187. package/host-cp/src/pr-cache.mjs +11 -2
  188. package/host-cp/src/server.mjs +128 -22
  189. 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.
@@ -219,6 +232,17 @@ function validateChunkInput(body) {
219
232
  return null;
220
233
  }
221
234
 
235
+ // P1 — promote idempotency window. Keyed by chunk_id; value is
236
+ // { worldId, specPath, promoteId, createdAt }. Entries expire after
237
+ // PROMOTE_DEDUPE_TTL_MS so a genuine re-promote (after operator intent)
238
+ // is allowed rather than silently re-routing to the original world.
239
+ // In-memory only: a process restart clears the map. For v1 this is
240
+ // sufficient — the same Linear-retry window (seconds) is far shorter
241
+ // than the TTL (30s). Persistent dedup (across restarts / multi-replica)
242
+ // is deferred to U2.
243
+ const PROMOTE_DEDUPE_TTL_MS = 30_000;
244
+ const _promoteDedupe = new Map(); // chunk_id → { worldId, specPath, promoteId, createdAt }
245
+
222
246
  /**
223
247
  * Build the HTTP request handler. Pure factory — easy to test against a
224
248
  * stubbed pool. Production callers pass a real pg.Pool.
@@ -240,6 +264,17 @@ export function createHandler({
240
264
  shapeDebugLog,
241
265
  createWorld,
242
266
  destroyWorld,
267
+ /**
268
+ * P1 — injectable dispatchTask callback. Accepts { worldId, containerName,
269
+ * task, tier } and dispatches the task to the newly-created world.
270
+ * Production callers wire in autoDispatchTask from @olam/core; tests inject
271
+ * a stub. When omitted, the promote endpoint returns 501.
272
+ *
273
+ * Tier routing: the caller is responsible for reading compute.default
274
+ * from the workspace config and passing it as opts.tier when applicable.
275
+ * This keeps plan-chat-service free from workspace-config coupling.
276
+ */
277
+ dispatchTask,
243
278
  /** B4 — optional override for tests. When supplied, replaces principalFromBearer
244
279
  * so the test harness can inject a hardcoded server-resolved actor_id and verify
245
280
  * the mismatch guard. Production callers omit this. */
@@ -248,6 +283,17 @@ export function createHandler({
248
283
  * default per-bearer rate limiter (60 req/min). Tests inject a stub with
249
284
  * lower capacity to exercise the 429 path quickly. Production callers omit. */
250
285
  rateLimiter,
286
+ /**
287
+ * P1 — override the dedupe map for tests. Allows tests to pre-populate
288
+ * or inspect the idempotency store. Production callers omit this.
289
+ */
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,
251
297
  }) {
252
298
  if (!pool) throw new Error('createHandler: { pool } required');
253
299
  if (typeof bearer !== 'string' || bearer.length === 0) {
@@ -270,6 +316,90 @@ export function createHandler({
270
316
  // through ids at line-rate without the bucket).
271
317
  const resolveLimiter = rateLimiter ?? createRateLimiter({ capacity: 60, windowMs: 60_000 });
272
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
+
273
403
  function checkAuth(req) {
274
404
  const header = req.headers.authorization;
275
405
  if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
@@ -518,12 +648,23 @@ export function createHandler({
518
648
  'world_id query param required (alphanumerics + _ - . only)',
519
649
  );
520
650
  }
521
- 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))) {
522
657
  return badRequest(
523
658
  res,
524
659
  'session_id query param required (alphanumerics + _ - . only)',
525
660
  );
526
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
+ }
527
668
 
528
669
  // Forward all client query params EXCEPT the scoped set. Client-supplied
529
670
  // `where` is silently dropped — server-derived `where` always wins.
@@ -539,14 +680,14 @@ export function createHandler({
539
680
  // PB2 — server-derived `where` clause; safe because SCOPE_ID_RE has
540
681
  // already rejected single-quotes, semicolons, and SQL meta-characters.
541
682
  //
542
- // Predicate order: session_id FIRST. Electric SQL caches shapes by the
543
- // exact predicate string; flipping the original (world_id, session_id)
544
- // order forces a fresh shape and avoids a known cache-staleness bug
545
- // where an empty-result shape persists after new rows land.
546
- upstream.searchParams.set(
547
- 'where',
548
- `session_id='${sessionId}' AND world_id='${worldId}'`,
549
- );
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);
550
691
 
551
692
  // Phase A A2 — log BEFORE upstream fetch. Includes the rewritten
552
693
  // `where` predicate so an operator can correlate client-supplied
@@ -655,6 +796,302 @@ export function createHandler({
655
796
  }
656
797
  }
657
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
+
658
1095
  async function handleGetPlanningSessions(req, res, url) {
659
1096
  if (!checkAuth(req)) return unauthorized(res);
660
1097
  // Derive actorId from the bearer principal (no body on GET).
@@ -837,12 +1274,231 @@ export function createHandler({
837
1274
  }
838
1275
  }
839
1276
 
1277
+ // P1 — POST /v1/prototypes/:id/promote
1278
+ //
1279
+ // Accepts a prototype chunk_id in the URL path. Validates the chunk exists
1280
+ // and is a prototype-shaped tool_use chunk, then dispatches a docker world
1281
+ // to implement it. Returns 202 Accepted with { worldId, promoteId, specPath }
1282
+ // before the docker world finishes booting (the boot is async).
1283
+ //
1284
+ // Idempotency: same chunk_id within PROMOTE_DEDUPE_TTL_MS returns
1285
+ // 202 { action: "deduplicated", worldId } without creating a second world.
1286
+ // Rationale: Linear retries on non-2xx; a 409 would trigger infinite retries.
1287
+ // 202 + action body is the correct idempotent-promote semantic.
1288
+ //
1289
+ // Tier routing: the injected `dispatchTask` callback owns tier selection.
1290
+ // The caller (production: WorldManager glue in server.mjs) passes the
1291
+ // workspace's `compute.default` tier as part of the dispatchTask closure.
1292
+ // This keeps plan-chat-service free from workspace config coupling.
1293
+ async function handlePostPrototypePromote(req, res, chunkId) {
1294
+ if (!checkAuth(req)) return unauthorized(res);
1295
+
1296
+ // createWorld + dispatchTask must both be wired. If either is absent,
1297
+ // surface a clear 501 rather than crashing (mirrors crystallize guard).
1298
+ if (typeof createWorld !== 'function' || typeof dispatchTask !== 'function') {
1299
+ return send(res, 501, {
1300
+ error: 'promote-not-wired',
1301
+ message: 'createWorld and dispatchTask callbacks are required',
1302
+ });
1303
+ }
1304
+
1305
+ // Idempotency: check dedupe map before any DB reads.
1306
+ const now = Date.now();
1307
+ const existing = promoteDedupe.get(chunkId);
1308
+ if (existing && now - existing.createdAt < PROMOTE_DEDUPE_TTL_MS) {
1309
+ return send(res, 202, {
1310
+ action: 'deduplicated',
1311
+ worldId: existing.worldId,
1312
+ promoteId: existing.promoteId,
1313
+ specPath: existing.specPath,
1314
+ });
1315
+ }
1316
+
1317
+ // Parse optional body { targetWorldName?, specName? }
1318
+ let body = {};
1319
+ try {
1320
+ body = await readJson(req);
1321
+ } catch (err) {
1322
+ return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
1323
+ }
1324
+
1325
+ // Look up the chunk by id in the chunks table.
1326
+ let chunk;
1327
+ try {
1328
+ const result = await pool.query(
1329
+ `SELECT world_id, session_id, message_id, seq, actor_id, actor_type,
1330
+ role, chunk, chunk_type
1331
+ FROM chunks WHERE message_id = $1
1332
+ LIMIT 1`,
1333
+ [chunkId],
1334
+ );
1335
+ chunk = result.rows[0] ?? null;
1336
+ } catch (err) {
1337
+ return send(res, 500, {
1338
+ error: 'query-failed',
1339
+ message: String(err?.message ?? err),
1340
+ });
1341
+ }
1342
+
1343
+ if (!chunk) {
1344
+ return send(res, 404, {
1345
+ error: 'chunk-not-found',
1346
+ message: `No chunk found with id '${chunkId}'`,
1347
+ });
1348
+ }
1349
+
1350
+ // Validate: must be a tool_use chunk with a prototype-shaped name.
1351
+ if (chunk.chunk_type !== 'tool_use') {
1352
+ return send(res, 400, {
1353
+ error: 'invalid-chunk-type',
1354
+ message: `chunk must have chunk_type='tool_use', got '${chunk.chunk_type}'`,
1355
+ });
1356
+ }
1357
+
1358
+ let parsedChunk;
1359
+ try {
1360
+ parsedChunk = JSON.parse(chunk.chunk);
1361
+ } catch {
1362
+ return send(res, 400, {
1363
+ error: 'invalid-chunk-json',
1364
+ message: 'chunk content is not valid JSON',
1365
+ });
1366
+ }
1367
+
1368
+ const toolName = typeof parsedChunk?.name === 'string' ? parsedChunk.name : '';
1369
+ if (!toolName.includes('prototype')) {
1370
+ return send(res, 400, {
1371
+ error: 'not-a-prototype-chunk',
1372
+ message: `chunk tool name '${toolName}' does not match prototype shape (name must include 'prototype')`,
1373
+ });
1374
+ }
1375
+
1376
+ // Derive world name and spec path from the chunk's input.
1377
+ const input = parsedChunk.input ?? {};
1378
+ const rawTitle =
1379
+ (typeof body.specName === 'string' && body.specName.length > 0
1380
+ ? body.specName
1381
+ : typeof input.title === 'string' && input.title.length > 0
1382
+ ? input.title
1383
+ : 'prototype')
1384
+ .toLowerCase()
1385
+ .replace(/[^a-z0-9]+/g, '-')
1386
+ .replace(/^-+|-+$/g, '')
1387
+ .slice(0, 40) || 'prototype';
1388
+
1389
+ const worldName =
1390
+ typeof body.targetWorldName === 'string' && body.targetWorldName.length > 0
1391
+ ? body.targetWorldName
1392
+ : `proto-${rawTitle}`;
1393
+
1394
+ const specPath = `design-specs/${rawTitle}-v1.spec.html`;
1395
+ const specSource =
1396
+ typeof input.source === 'string' ? input.source : JSON.stringify(input);
1397
+
1398
+ // Build the dispatch prompt: embed the spec source + instruct the agent
1399
+ // to write it at specPath on first tool use. Per D3: the agent inside
1400
+ // the world writes the file — host-cp does not need docker mount access.
1401
+ const dispatchPrompt = [
1402
+ `## Prototype implementation task`,
1403
+ ``,
1404
+ `A prototype has been promoted for implementation. Write the spec file`,
1405
+ `at \`${specPath}\` as your first action, then implement it production-ready.`,
1406
+ ``,
1407
+ `### Spec source (write verbatim to ${specPath})`,
1408
+ ``,
1409
+ `\`\`\`html`,
1410
+ specSource,
1411
+ `\`\`\``,
1412
+ ``,
1413
+ `After writing the spec file, implement it fully — components, styles,`,
1414
+ `tests, and documentation. The spec is the source of truth.`,
1415
+ `If a file already exists at \`${specPath}\`, surface a conflict chunk`,
1416
+ `to the operator before overwriting (per D4 of the promote endpoint plan).`,
1417
+ ].join('\n');
1418
+
1419
+ // Create the world.
1420
+ let worldId;
1421
+ try {
1422
+ const world = await createWorld({ name: worldName });
1423
+ worldId = world.id;
1424
+ } catch (err) {
1425
+ return send(res, 500, {
1426
+ error: 'world-creation-failed',
1427
+ message: String(err?.message ?? err),
1428
+ });
1429
+ }
1430
+
1431
+ // Dispatch the task to the new world.
1432
+ try {
1433
+ await dispatchTask({ worldId, task: dispatchPrompt });
1434
+ } catch (err) {
1435
+ // The world was created but dispatch failed. Surface 502 so the caller
1436
+ // can retry dispatch manually via `olam dispatch <id> "<task>"`.
1437
+ return send(res, 502, {
1438
+ error: 'dispatch-failed',
1439
+ worldId,
1440
+ message: String(err?.message ?? err),
1441
+ });
1442
+ }
1443
+
1444
+ const promoteId = `promote-${chunkId}-${worldId}`;
1445
+
1446
+ // Record in dedupe map before returning.
1447
+ promoteDedupe.set(chunkId, { worldId, specPath, promoteId, createdAt: now });
1448
+
1449
+ return send(res, 202, { worldId, promoteId, specPath, status: 'dispatched' });
1450
+ }
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
+
840
1475
  return async function handler(req, res) {
841
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
+ }
842
1487
  if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
843
1488
  if (req.method === 'POST' && url.pathname === '/v1/chunks') return handlePostChunks(req, res);
844
1489
  if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
845
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
+ }
846
1502
  if (req.method === 'POST' && url.pathname === '/v1/crystallize') return handlePostCrystallize(req, res);
847
1503
  // Phase A A1 — /v1/resolve/:id (plan-chat-spa-supersedes-control-plane).
848
1504
  const resolveMatch = /^\/v1\/resolve\/([^/]+)$/.exec(url.pathname);
@@ -859,6 +1515,16 @@ export function createHandler({
859
1515
  if (req.method === 'PATCH') return handlePatchArtifact(req, res, id);
860
1516
  return send(res, 405, { error: 'method-not-allowed' });
861
1517
  }
1518
+
1519
+ // P1 — /v1/prototypes/:id/promote
1520
+ // startsWith + endsWith guard prevents shadowing a future /v1/prototypes
1521
+ // GET list route (per risk note in the plan). The match is exact.
1522
+ const promoteMatch = /^\/v1\/prototypes\/([^/]+)\/promote$/.exec(url.pathname);
1523
+ if (promoteMatch && req.method === 'POST') {
1524
+ const chunkId = decodeURIComponent(promoteMatch[1]);
1525
+ return handlePostPrototypePromote(req, res, chunkId);
1526
+ }
1527
+
862
1528
  return send(res, 404, { error: 'not-found' });
863
1529
  };
864
1530
  }
@@ -883,14 +1549,46 @@ export async function startService(opts = {}) {
883
1549
  const bearer = opts.bearer ?? ensureSecret(secretPath);
884
1550
 
885
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
+
886
1579
  const handler = createHandler({
887
1580
  pool,
888
1581
  bearer,
889
1582
  electricUrl,
890
1583
  shapeDebug: opts.shapeDebug,
891
1584
  shapeDebugLog: opts.shapeDebugLog,
1585
+ createWorld: opts.createWorld,
1586
+ destroyWorld: opts.destroyWorld,
1587
+ dispatchTask: opts.dispatchTask,
892
1588
  resolveActor: opts.resolveActor,
893
1589
  rateLimiter: opts.rateLimiter,
1590
+ _promoteDedupe: opts._promoteDedupe,
1591
+ dispatchTurnForward: opts.dispatchTurnForward,
894
1592
  });
895
1593
  const server = http.createServer((req, res) => {
896
1594
  handler(req, res).catch((err) => {