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