@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.
- package/README.md +1 -1
- package/dist/ask/knowledge-pack-builder.d.ts.map +1 -1
- package/dist/ask/knowledge-pack-builder.js +5 -0
- package/dist/ask/knowledge-pack-builder.js.map +1 -1
- package/dist/ask/knowledge-pack.generated.d.ts.map +1 -1
- package/dist/ask/knowledge-pack.generated.js +442 -33
- package/dist/ask/knowledge-pack.generated.js.map +1 -1
- package/dist/commands/auth-status.js +2 -2
- package/dist/commands/auth-status.js.map +1 -1
- package/dist/commands/auth.js +1 -1
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/bootstrap.d.ts +4 -0
- package/dist/commands/bootstrap.d.ts.map +1 -1
- package/dist/commands/bootstrap.js +6 -9
- package/dist/commands/bootstrap.js.map +1 -1
- package/dist/commands/clean.js +1 -1
- package/dist/commands/clean.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +1 -4
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +10 -0
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/crystallize.js +12 -14
- package/dist/commands/crystallize.js.map +1 -1
- package/dist/commands/destroy.d.ts +13 -1
- package/dist/commands/destroy.d.ts.map +1 -1
- package/dist/commands/destroy.js +52 -6
- package/dist/commands/destroy.js.map +1 -1
- package/dist/commands/dispatch.d.ts +9 -0
- package/dist/commands/dispatch.d.ts.map +1 -1
- package/dist/commands/dispatch.js +21 -2
- package/dist/commands/dispatch.js.map +1 -1
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +29 -22
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/enter.d.ts +3 -3
- package/dist/commands/enter.d.ts.map +1 -1
- package/dist/commands/enter.js +57 -44
- package/dist/commands/enter.js.map +1 -1
- package/dist/commands/flywheel/index.d.ts.map +1 -1
- package/dist/commands/flywheel/index.js +1 -1
- package/dist/commands/flywheel/index.js.map +1 -1
- package/dist/commands/host-cp.d.ts.map +1 -1
- package/dist/commands/host-cp.js +2 -1
- package/dist/commands/host-cp.js.map +1 -1
- package/dist/commands/implode.d.ts.map +1 -1
- package/dist/commands/implode.js +1 -1
- package/dist/commands/implode.js.map +1 -1
- package/dist/commands/init.d.ts +20 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +102 -9
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/install.js +2 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/kg-build.d.ts.map +1 -1
- package/dist/commands/kg-build.js +3 -0
- package/dist/commands/kg-build.js.map +1 -1
- package/dist/commands/kg-classify.d.ts +20 -0
- package/dist/commands/kg-classify.d.ts.map +1 -1
- package/dist/commands/kg-classify.js +59 -42
- package/dist/commands/kg-classify.js.map +1 -1
- package/dist/commands/kg-mirror.d.ts +40 -0
- package/dist/commands/kg-mirror.d.ts.map +1 -0
- package/dist/commands/kg-mirror.js +228 -0
- package/dist/commands/kg-mirror.js.map +1 -0
- package/dist/commands/mcp/index.js +1 -1
- package/dist/commands/mcp/index.js.map +1 -1
- package/dist/commands/memory/index.d.ts.map +1 -1
- package/dist/commands/memory/index.js +1 -1
- package/dist/commands/memory/index.js.map +1 -1
- package/dist/commands/resume.d.ts.map +1 -1
- package/dist/commands/resume.js +1 -1
- package/dist/commands/resume.js.map +1 -1
- package/dist/commands/services-tls.d.ts +120 -0
- package/dist/commands/services-tls.d.ts.map +1 -0
- package/dist/commands/services-tls.js +434 -0
- package/dist/commands/services-tls.js.map +1 -0
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +40 -1
- package/dist/commands/services.js.map +1 -1
- package/dist/commands/setup-linux-gate.d.ts.map +1 -1
- package/dist/commands/setup-linux-gate.js +1 -3
- package/dist/commands/setup-linux-gate.js.map +1 -1
- package/dist/commands/setup-metrics.d.ts.map +1 -1
- package/dist/commands/setup-metrics.js +1 -2
- package/dist/commands/setup-metrics.js.map +1 -1
- package/dist/commands/setup-phase-5a-skill-source.d.ts +17 -1
- package/dist/commands/setup-phase-5a-skill-source.d.ts.map +1 -1
- package/dist/commands/setup-phase-5a-skill-source.js +69 -6
- package/dist/commands/setup-phase-5a-skill-source.js.map +1 -1
- package/dist/commands/setup.d.ts +26 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +189 -47
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/skills-onboard.d.ts.map +1 -1
- package/dist/commands/skills-onboard.js +4 -1
- package/dist/commands/skills-onboard.js.map +1 -1
- package/dist/commands/skills-source.d.ts.map +1 -1
- package/dist/commands/skills-source.js +20 -4
- package/dist/commands/skills-source.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +5 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +1 -3
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/commands/yolo.d.ts.map +1 -1
- package/dist/commands/yolo.js +1 -1
- package/dist/commands/yolo.js.map +1 -1
- package/dist/context.d.ts +4 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -2
- package/dist/context.js.map +1 -1
- package/dist/image-digests.json +8 -8
- package/dist/index.js +4409 -2375
- package/dist/index.js.map +1 -1
- package/dist/lib/auth-refresh-kubernetes.d.ts.map +1 -1
- package/dist/lib/auth-refresh-kubernetes.js +14 -5
- package/dist/lib/auth-refresh-kubernetes.js.map +1 -1
- package/dist/lib/bootstrap-kubernetes.d.ts +41 -0
- package/dist/lib/bootstrap-kubernetes.d.ts.map +1 -1
- package/dist/lib/bootstrap-kubernetes.js +289 -36
- package/dist/lib/bootstrap-kubernetes.js.map +1 -1
- package/dist/lib/cf-access-token.d.ts.map +1 -1
- package/dist/lib/cf-access-token.js +2 -3
- package/dist/lib/cf-access-token.js.map +1 -1
- package/dist/lib/health-probes.d.ts +14 -0
- package/dist/lib/health-probes.d.ts.map +1 -1
- package/dist/lib/health-probes.js +41 -3
- package/dist/lib/health-probes.js.map +1 -1
- package/dist/lib/help-groups.d.ts +36 -0
- package/dist/lib/help-groups.d.ts.map +1 -0
- package/dist/lib/help-groups.js +124 -0
- package/dist/lib/help-groups.js.map +1 -0
- package/dist/lib/k8s-bootstrap.d.ts +6 -0
- package/dist/lib/k8s-bootstrap.d.ts.map +1 -1
- package/dist/lib/k8s-bootstrap.js +15 -2
- package/dist/lib/k8s-bootstrap.js.map +1 -1
- package/dist/lib/k8s-secret-render.d.ts.map +1 -1
- package/dist/lib/k8s-secret-render.js +17 -10
- package/dist/lib/k8s-secret-render.js.map +1 -1
- package/dist/lib/memory-secret.d.ts +15 -2
- package/dist/lib/memory-secret.d.ts.map +1 -1
- package/dist/lib/memory-secret.js +25 -8
- package/dist/lib/memory-secret.js.map +1 -1
- package/dist/lib/upgrade-check.d.ts +60 -0
- package/dist/lib/upgrade-check.d.ts.map +1 -0
- package/dist/lib/upgrade-check.js +169 -0
- package/dist/lib/upgrade-check.js.map +1 -0
- package/dist/lib/upgrade-kubernetes.d.ts +17 -0
- package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
- package/dist/lib/upgrade-kubernetes.js +125 -1
- package/dist/lib/upgrade-kubernetes.js.map +1 -1
- package/dist/mcp-server.js +2687 -2818
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/30-configmap.yaml +8 -1
- package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/60-service.yaml +12 -4
- package/host-cp/k8s/manifests/70-ingressroute.yaml +58 -0
- package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/chunks-electric/10-serviceaccount.yaml +8 -0
- package/host-cp/k8s/manifests/chunks-electric/20-rbac.yaml +27 -0
- package/host-cp/k8s/manifests/chunks-electric/30-configmap.yaml +23 -0
- package/host-cp/k8s/manifests/chunks-electric/45-pvc.yaml +19 -0
- package/host-cp/k8s/manifests/chunks-electric/50-deployment.yaml +84 -0
- package/host-cp/k8s/manifests/chunks-electric/60-service.yaml +17 -0
- package/host-cp/k8s/manifests/chunks-postgres/10-serviceaccount.yaml +8 -0
- package/host-cp/k8s/manifests/chunks-postgres/20-rbac.yaml +29 -0
- package/host-cp/k8s/manifests/chunks-postgres/30-configmap.yaml +185 -0
- package/host-cp/k8s/manifests/chunks-postgres/45-pvc.yaml +24 -0
- package/host-cp/k8s/manifests/chunks-postgres/50-deployment.yaml +101 -0
- package/host-cp/k8s/manifests/chunks-postgres/60-service.yaml +24 -0
- package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/plan-chat-service/10-serviceaccount.yaml +8 -0
- package/host-cp/k8s/manifests/plan-chat-service/20-rbac.yaml +29 -0
- package/host-cp/k8s/manifests/plan-chat-service/30-configmap.yaml +36 -0
- package/host-cp/k8s/manifests/plan-chat-service/45-pvc.yaml +24 -0
- package/host-cp/k8s/manifests/plan-chat-service/50-deployment.yaml +135 -0
- package/host-cp/k8s/manifests/plan-chat-service/60-service.yaml +17 -0
- package/host-cp/src/plan-chat-secret.mjs +16 -1
- package/host-cp/src/plan-chat-service.mjs +709 -11
- package/host-cp/src/planning-sessions.mjs +252 -0
- package/host-cp/src/pr-cache.mjs +11 -2
- package/host-cp/src/server.mjs +128 -22
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
543
|
-
//
|
|
544
|
-
// order forces a fresh shape and avoids a known cache-staleness
|
|
545
|
-
// where an empty-result shape persists after new rows land.
|
|
546
|
-
|
|
547
|
-
'
|
|
548
|
-
`
|
|
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) => {
|