@pleri/olam-cli 0.1.170 → 0.1.174

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 (37) hide show
  1. package/dist/agent-stream/driver-runner.js +13 -0
  2. package/dist/commands/auth.d.ts +22 -7
  3. package/dist/commands/auth.d.ts.map +1 -1
  4. package/dist/commands/auth.js +414 -46
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/create.d.ts.map +1 -1
  7. package/dist/commands/create.js +45 -1
  8. package/dist/commands/create.js.map +1 -1
  9. package/dist/commands/services.d.ts +39 -0
  10. package/dist/commands/services.d.ts.map +1 -1
  11. package/dist/commands/services.js +64 -9
  12. package/dist/commands/services.js.map +1 -1
  13. package/dist/from-manifest.d.ts +53 -0
  14. package/dist/from-manifest.d.ts.map +1 -0
  15. package/dist/from-manifest.js +95 -0
  16. package/dist/from-manifest.js.map +1 -0
  17. package/dist/image-digests.json +8 -8
  18. package/dist/index.js +911 -137
  19. package/dist/lib/auth-remote.d.ts +130 -0
  20. package/dist/lib/auth-remote.d.ts.map +1 -0
  21. package/dist/lib/auth-remote.js +307 -0
  22. package/dist/lib/auth-remote.js.map +1 -0
  23. package/dist/mcp-server.js +1487 -435
  24. package/hermes-bundle/version.json +1 -1
  25. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  26. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  27. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  28. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  29. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  30. package/host-cp/observability/ndjson-span-sink.mjs +52 -0
  31. package/host-cp/src/boot-reconciler.mjs +238 -0
  32. package/host-cp/src/linear-sync.mjs +43 -0
  33. package/host-cp/src/plan-chat-service.mjs +129 -1
  34. package/host-cp/src/port-bridge-manager.mjs +116 -10
  35. package/host-cp/src/server.mjs +121 -1
  36. package/host-cp/src/world-activity-tracker.mjs +392 -0
  37. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
1
  {
2
- "bundledAt": "2026-05-24T03:12:41.846Z",
2
+ "bundledAt": "2026-05-24T12:50:15.815Z",
3
3
  "kgFirstSha": "29a9ccce1b115d049e375c4a90eb5cf7c123e610e2d0590270a4db2cdbc64a28"
4
4
  }
@@ -118,7 +118,7 @@ spec:
118
118
  # k3d), started by `olam upgrade` Step 0.7 — not inside this Pod.
119
119
  containers:
120
120
  - name: olam-host-cp
121
- image: ghcr.io/pleri/olam-host-cp@sha256:1206e857af61f8907d76d9324adbe8d2d5638a94fe2411c6713ffb4f570e8f58
121
+ image: ghcr.io/pleri/olam-host-cp@sha256:78ab85611487425028f9843dda1cf48fe32cfaac56e0ba180e37f0b7c327d2fd
122
122
  imagePullPolicy: IfNotPresent
123
123
  securityContext:
124
124
  runAsNonRoot: true
@@ -70,7 +70,7 @@ spec:
70
70
  mountPath: /data
71
71
  containers:
72
72
  - name: olam-auth-service
73
- image: ghcr.io/pleri/olam-auth@sha256:2d32d178380641bcdae11f9ad05851238bd4b121adfc9638c8abed3b25467846
73
+ image: ghcr.io/pleri/olam-auth@sha256:b7f79147b521f8e7fc8f962e623334f6ae4ea98e893a3fa8f6c81f3916d01f11
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -61,7 +61,7 @@ spec:
61
61
  mountPath: /data
62
62
  containers:
63
63
  - name: olam-kg-service
64
- image: ghcr.io/pleri/olam-kg-service@sha256:ee636804b8cffd40a1fb75ba3f79cc0c30a17e89c9a135864567859ccdf895d7
64
+ image: ghcr.io/pleri/olam-kg-service@sha256:7ca19574cc018e3ceaef7eb0421d3539aa361e1f1ab95a4df5ad400de45d9c41
65
65
  imagePullPolicy: IfNotPresent
66
66
  securityContext:
67
67
  runAsNonRoot: true
@@ -68,7 +68,7 @@ spec:
68
68
  mountPath: /data
69
69
  containers:
70
70
  - name: olam-mcp-auth-service
71
- image: ghcr.io/pleri/olam-mcp-auth@sha256:07cdd816ac1d991c065f2936b142a5c6909da683d9a6d4efbe7fe66f0c811821
71
+ image: ghcr.io/pleri/olam-mcp-auth@sha256:223f5aa65736fbf0bc96b86cf01d4acf447856d18e5a5e50467eff714b922ed3
72
72
  imagePullPolicy: IfNotPresent
73
73
  securityContext:
74
74
  runAsNonRoot: true
@@ -70,7 +70,7 @@ spec:
70
70
  # bootstrap-placeholder comment + run `npm run refresh:manifest-digests`
71
71
  # once ghcr.io/pleri/olam-memory-service has a real published digest.
72
72
  # bootstrap-placeholder: pre-publish; refresh after first release
73
- image: ghcr.io/pleri/olam-memory-service@sha256:20443e8e6725151f7523a8a85c73c7449767782de1d03bb172ba395df19a0939
73
+ image: ghcr.io/pleri/olam-memory-service@sha256:5db73eab55738ac6eb79150363d6d98daf26e5bc43e626231de146e40e908015
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -102,6 +102,58 @@ export async function createNdjsonSpanSink({
102
102
  };
103
103
  }
104
104
 
105
+ /**
106
+ * Subscribe an NDJSON sink to `@olam/auth-client`'s `betaResponseEmitter`.
107
+ * Each `beta-response` event becomes a `withCredential.beta-response` span
108
+ * with the beta payload exploded onto `attributes` — downstream `jq`
109
+ * consumers can query e.g.
110
+ *
111
+ * jq 'select(.name == "withCredential.beta-response")
112
+ * | {ts: .startedAt, cred: .attributes.credentialName,
113
+ * cache: .attributes.cacheStatus,
114
+ * thinking: .attributes.thinkingTokens,
115
+ * latencyMs: .durationMs}' ~/.olam/logs/host.trace.ndjson
116
+ *
117
+ * Wire is opt-in (call from server boot). Returns a detach function so the
118
+ * subscription can be removed in tests or on shutdown.
119
+ *
120
+ * Pure additive: spans flowing from other sources (docker lifecycle,
121
+ * plan-orchestrator, etc.) are unaffected.
122
+ */
123
+ export function attachBetaResponseEvents({ sink, emitter }) {
124
+ if (!sink || typeof sink.recordSpan !== 'function') {
125
+ throw new Error('attachBetaResponseEvents: sink.recordSpan required');
126
+ }
127
+ if (!emitter || typeof emitter.on !== 'function') {
128
+ throw new Error('attachBetaResponseEvents: emitter.on required');
129
+ }
130
+
131
+ const handler = (info) => {
132
+ const now = Date.now();
133
+ const latency = typeof info?.latencyMs === 'number' ? info.latencyMs : 0;
134
+ sink.recordSpan({
135
+ name: 'withCredential.beta-response',
136
+ startedAt: now - latency,
137
+ endedAt: now,
138
+ attributes: {
139
+ credentialName: info?.credentialName ?? null,
140
+ credId: info?.credId ?? null,
141
+ betas: Array.isArray(info?.betas) ? [...info.betas] : [],
142
+ cacheStatus: info?.cacheStatus ?? null,
143
+ thinkingTokens: info?.tokenCounts?.thinking ?? null,
144
+ statusCode: typeof info?.statusCode === 'number' ? info.statusCode : null,
145
+ extraHeaders: info?.extraHeaders && typeof info.extraHeaders === 'object'
146
+ ? { ...info.extraHeaders }
147
+ : {},
148
+ },
149
+ exit: { _tag: 'Success' },
150
+ });
151
+ };
152
+
153
+ emitter.on('beta-response', handler);
154
+ return () => emitter.off('beta-response', handler);
155
+ }
156
+
105
157
  // Duck-typed ServerResponse for host-stream's `addSink`. Parses SSE frames
106
158
  // (`event: <type>\ndata: <json>\n\n`) and dispatches `event: span` payloads
107
159
  // to `onSpan`. All other event types are silently ignored — host-stream
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Boot-time reconciler — sync worlds.db with live docker state.
3
+ *
4
+ * Problem (issue #963): after Colima / userspace restart, host-cp can
5
+ * start with worlds.db rows that no longer reflect docker reality. The
6
+ * existing `worlds-db-source.mjs` reconciler runs DB→registry (reads
7
+ * 'running' rows and adds them to in-memory WORLDS). It does NOT heal
8
+ * the inverse case: a container is alive on docker but worlds.db has
9
+ * no row (Hazel coral-sky-2478 scenario), or worlds.db says a world is
10
+ * running but the container is gone (orphaned row).
11
+ *
12
+ * This module fills both gaps with a one-shot pass at boot:
13
+ *
14
+ * 1. List `olam-*-devbox` containers via the docker API.
15
+ * 2. For each container, derive the worldId (strip prefix + suffix).
16
+ * 3. Cross-check against worlds.db rows:
17
+ * - container alive, row exists → no-op
18
+ * - container alive, row missing → INSERT (status=reconciled)
19
+ * - row says running/active, container missing → UPDATE status=orphaned
20
+ *
21
+ * Fail-soft: if the docker daemon is unreachable OR better-sqlite3 is
22
+ * not available, the function logs a warning and returns without
23
+ * throwing. Server boot continues.
24
+ *
25
+ * Idempotent: a second invocation against the same docker + DB state
26
+ * produces no further changes (existing rows are skipped at step 3a,
27
+ * already-orphaned rows are skipped at step 3c).
28
+ *
29
+ * Coordination with issue #962: the dedup logic in `olam create` handles
30
+ * per-call deduplication; this reconciler handles boot-time cleanup.
31
+ * They don't conflict — both operate on the worlds.db source-of-truth.
32
+ */
33
+
34
+ import { createRequire } from 'node:module';
35
+
36
+ const require = createRequire(import.meta.url);
37
+
38
+ const CONTAINER_NAME_PATTERN = /^\/?(olam-(.+)-devbox)$/;
39
+
40
+ /**
41
+ * @typedef {object} ReconcileDeps
42
+ * @property {string} dbPath Path to worlds.db
43
+ * @property {() => Promise<string[] | null>} listContainerNames Returns null when docker is unreachable
44
+ * @property {(msg: string) => void} [log] Defaults to console.log
45
+ * @property {() => string} [now] ISO timestamp generator (overridable for tests)
46
+ * @property {(path: string) => unknown | null} [openDb] Overridable DB opener (tests inject fakes)
47
+ */
48
+
49
+ /**
50
+ * @typedef {object} ReconcileSummary
51
+ * @property {number} inserted Number of new rows inserted (reconciled containers)
52
+ * @property {number} orphaned Number of rows transitioned to status='orphaned'
53
+ * @property {number} skipped Containers/rows where no change was needed
54
+ * @property {boolean} dockerUnreachable
55
+ * @property {boolean} dbUnavailable
56
+ */
57
+
58
+ /**
59
+ * Extract a worldId from a docker container name.
60
+ * Accepts either `olam-foo-bar-1234-devbox` or `/olam-foo-bar-1234-devbox`
61
+ * (the docker API prefixes container names with a slash).
62
+ *
63
+ * @param {string} name
64
+ * @returns {string | null}
65
+ */
66
+ export function extractWorldIdFromContainerName(name) {
67
+ if (typeof name !== 'string') return null;
68
+ const match = CONTAINER_NAME_PATTERN.exec(name);
69
+ if (!match) return null;
70
+ const worldId = match[2];
71
+ if (!worldId || worldId.length === 0) return null;
72
+ return worldId;
73
+ }
74
+
75
+ /**
76
+ * Default docker container lister. Hits the Docker Engine API.
77
+ * Returns null on any failure (fail-soft).
78
+ *
79
+ * @param {string} dockerApiBase e.g. 'http://docker-socket-proxy:2375'
80
+ * @param {(msg: string) => void} log
81
+ * @returns {Promise<string[] | null>}
82
+ */
83
+ export async function defaultListContainerNames(dockerApiBase, log) {
84
+ if (!dockerApiBase || dockerApiBase === 'http://localhost:2375') {
85
+ // 'docker-cli' sentinel; no API available in this deployment mode.
86
+ log('[boot-reconciler] docker API unavailable (bare-node mode); skipping');
87
+ return null;
88
+ }
89
+ try {
90
+ const filters = encodeURIComponent(JSON.stringify({ name: ['olam-'] }));
91
+ const url = `${dockerApiBase}/containers/json?filters=${filters}`;
92
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
93
+ if (!res.ok) {
94
+ log(`[boot-reconciler] docker /containers/json returned ${res.status}; skipping`);
95
+ return null;
96
+ }
97
+ const data = await res.json();
98
+ if (!Array.isArray(data)) return [];
99
+ const names = [];
100
+ for (const container of data) {
101
+ const list = container?.Names;
102
+ if (!Array.isArray(list)) continue;
103
+ for (const n of list) {
104
+ if (typeof n === 'string') names.push(n);
105
+ }
106
+ }
107
+ return names;
108
+ } catch (err) {
109
+ log(`[boot-reconciler] docker query failed: ${err.message}; skipping`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Default DB opener. Loads better-sqlite3 dynamically so a missing
116
+ * native build degrades gracefully instead of crashing host-cp boot.
117
+ *
118
+ * @param {string} dbPath
119
+ * @param {(msg: string) => void} log
120
+ * @returns {unknown | null}
121
+ */
122
+ export function defaultOpenDb(dbPath, log) {
123
+ try {
124
+ const Database = require('better-sqlite3');
125
+ return new Database(dbPath, { fileMustExist: true });
126
+ } catch (err) {
127
+ if (err && err.code === 'MODULE_NOT_FOUND') {
128
+ log('[boot-reconciler] better-sqlite3 not available; skipping');
129
+ } else if (err && err.code === 'SQLITE_CANTOPEN') {
130
+ log(`[boot-reconciler] ${dbPath} not found; nothing to reconcile`);
131
+ } else {
132
+ log(`[boot-reconciler] failed to open ${dbPath}: ${err.message}`);
133
+ }
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Run a single boot-time reconciliation pass. Pure and dep-injected
140
+ * for testability.
141
+ *
142
+ * @param {ReconcileDeps} deps
143
+ * @returns {Promise<ReconcileSummary>}
144
+ */
145
+ export async function reconcileWorldsWithDocker(deps) {
146
+ const log = deps.log ?? console.log;
147
+ const now = deps.now ?? (() => new Date().toISOString());
148
+ const openDb = deps.openDb ?? ((p) => defaultOpenDb(p, log));
149
+
150
+ const summary = {
151
+ inserted: 0,
152
+ orphaned: 0,
153
+ skipped: 0,
154
+ dockerUnreachable: false,
155
+ dbUnavailable: false,
156
+ };
157
+
158
+ const containerNames = await deps.listContainerNames();
159
+ if (containerNames === null) {
160
+ summary.dockerUnreachable = true;
161
+ return summary;
162
+ }
163
+
164
+ const liveWorldIds = new Set();
165
+ for (const name of containerNames) {
166
+ const worldId = extractWorldIdFromContainerName(name);
167
+ if (worldId) liveWorldIds.add(worldId);
168
+ }
169
+
170
+ const db = openDb(deps.dbPath);
171
+ if (!db) {
172
+ summary.dbUnavailable = true;
173
+ return summary;
174
+ }
175
+
176
+ try {
177
+ /** @type {Array<{ id: string, status: string }>} */
178
+ let rows;
179
+ try {
180
+ rows = db.prepare('SELECT id, status FROM worlds').all();
181
+ } catch (err) {
182
+ log(`[boot-reconciler] query failed: ${err.message}; skipping`);
183
+ summary.dbUnavailable = true;
184
+ return summary;
185
+ }
186
+
187
+ const dbWorlds = new Map(rows.map((r) => [r.id, r.status]));
188
+
189
+ // Pass 1: containers alive but missing from DB → insert.
190
+ const insertStmt = db.prepare(
191
+ `INSERT INTO worlds
192
+ (id, name, status, repos, branch, port_offset, workspace_path,
193
+ compute_provider, total_cost_usd, thought_count, created_at, updated_at)
194
+ VALUES (?, ?, 'reconciled', '[]', 'main', 0, ?, 'docker', 0, 0, ?, ?)`,
195
+ );
196
+ for (const worldId of liveWorldIds) {
197
+ if (dbWorlds.has(worldId)) {
198
+ summary.skipped += 1;
199
+ continue;
200
+ }
201
+ const ts = now();
202
+ const workspacePath = `~/.olam/worlds/${worldId}`;
203
+ try {
204
+ insertStmt.run(worldId, worldId, workspacePath, ts, ts);
205
+ summary.inserted += 1;
206
+ log(`[boot-reconciler] inserted reconciled row for ${worldId} (container alive, no DB row)`);
207
+ } catch (err) {
208
+ log(`[boot-reconciler] failed to insert ${worldId}: ${err.message}`);
209
+ }
210
+ }
211
+
212
+ // Pass 2: DB says alive but container missing → mark orphaned.
213
+ const orphanStmt = db.prepare(
214
+ `UPDATE worlds SET status = 'orphaned', updated_at = ? WHERE id = ?`,
215
+ );
216
+ const aliveStatuses = new Set(['running', 'active', 'creating']);
217
+ for (const [worldId, status] of dbWorlds) {
218
+ if (liveWorldIds.has(worldId)) continue;
219
+ if (!aliveStatuses.has(status)) continue;
220
+ try {
221
+ orphanStmt.run(now(), worldId);
222
+ summary.orphaned += 1;
223
+ log(`[boot-reconciler] marked ${worldId} as orphaned (was '${status}', container missing)`);
224
+ } catch (err) {
225
+ log(`[boot-reconciler] failed to mark ${worldId} orphaned: ${err.message}`);
226
+ }
227
+ }
228
+
229
+ log(
230
+ `[boot-reconciler] complete: inserted=${summary.inserted} orphaned=${summary.orphaned} ` +
231
+ `skipped=${summary.skipped} live-containers=${liveWorldIds.size}`,
232
+ );
233
+ } finally {
234
+ try { db.close?.(); } catch { /* ignore */ }
235
+ }
236
+
237
+ return summary;
238
+ }
@@ -0,0 +1,43 @@
1
+ // H6 (Phase G) — Linear outbound sync skeleton.
2
+ //
3
+ // When a planning_artifacts row is created via H2 chunk extraction AND
4
+ // the operator has Linear MCP active, host-cp posts a new Linear issue
5
+ // (OR appends a comment to an existing linked issue if linear_issue_url
6
+ // is already populated on the row).
7
+ //
8
+ // PHASE G SHIPS SKELETON ONLY. The MCP-from-host wiring is not yet in
9
+ // host-cp; full Linear outbound posting lands in a follow-up commit
10
+ // when the MCP runtime story for host-cp is settled (today MCP lives in
11
+ // the operator's Claude Code runtime, not host-cp).
12
+ //
13
+ // Reverse channel (incoming Linear webhooks update artifact row status):
14
+ // EXPLICITLY OUT OF SCOPE for Phase G per plan body Out of scope list.
15
+ // Deferred to follow-up plan.
16
+
17
+ /**
18
+ * Best-effort Linear outbound sync. Returns null when MCP is unavailable
19
+ * (the typical case today — silent no-op). When MCP wires in, this
20
+ * function resolves to the posted issue URL which the caller can persist
21
+ * back to planning_artifacts.linear_issue_url.
22
+ *
23
+ * @param {object} args
24
+ * @param {string} args.artifactId
25
+ * @param {string} args.title
26
+ * @param {unknown} args.body — JSON body of the artifact
27
+ * @param {string} args.sessionId
28
+ * @returns {Promise<string | null>} — Linear issue URL or null
29
+ */
30
+ export async function syncArtifactToLinear({ artifactId, title, body, sessionId }) {
31
+ // Probe for Linear MCP availability via host-cp's bootstrap config (TBD).
32
+ // Until that surface exists, return null. Logging the intent surfaces
33
+ // the wiring gap to operators inspecting host-cp logs.
34
+ void artifactId;
35
+ void title;
36
+ void body;
37
+ void sessionId;
38
+ console.log(
39
+ `[linear-sync] outbound skip — Linear MCP not yet wired to host-cp; ` +
40
+ `artifactId=${artifactId} sessionId=${sessionId}`,
41
+ );
42
+ return null;
43
+ }
@@ -68,7 +68,7 @@ const SCOPE_ID_RE = /^[A-Za-z0-9_.-]+$/;
68
68
  // /v1/shape. Only these tables have server-side where-rewrite support; any
69
69
  // other table=... param gets a 400. Guards against a client enumerating
70
70
  // tables the service doesn't own.
71
- const ALLOWED_SHAPE_TABLES = new Set(['chunks', 'message_usage']);
71
+ const ALLOWED_SHAPE_TABLES = new Set(['chunks', 'message_usage', 'planning_artifacts']);
72
72
 
73
73
  // B6 (plan-chat-context-window-display Phase B): context-window caps per
74
74
  // model. Mirrors CONTEXT_CAPS from @olam/intelligence/src/llm-router/providers/claude.ts.
@@ -399,6 +399,44 @@ export function createHandler({
399
399
  }
400
400
  }
401
401
  }
402
+ // H2 (plan-chat-spa-canonical-surface Phase G) — extract commit_plan /
403
+ // propose_plan tool_use chunks into the planning_artifacts mutable
404
+ // table. The chunk's content carries JSON-stringified {name, input}
405
+ // per the substrate contract; we parse it once + write a single row.
406
+ // Failure to extract is logged + swallowed — the chunk itself is
407
+ // already persisted; artifact row absence is recoverable via
408
+ // re-extraction from chunks in a follow-up batch job.
409
+ if (body.chunk_type === 'tool_use') {
410
+ try {
411
+ const parsed = JSON.parse(body.chunk);
412
+ const toolName = typeof parsed?.name === 'string' ? parsed.name : '';
413
+ const artifactType = (toolName === 'commit_plan' || toolName === 'propose_plan')
414
+ ? 'commit_plan'
415
+ : toolName === 'component_scaffold'
416
+ ? 'component_scaffold'
417
+ : toolName === 'design_jam'
418
+ ? 'design_jam'
419
+ : null;
420
+ if (artifactType && parsed.input && typeof parsed.input === 'object') {
421
+ const input = parsed.input;
422
+ const title = typeof input.title === 'string' && input.title.length > 0
423
+ ? input.title.slice(0, 200)
424
+ : `Untitled ${artifactType}`;
425
+ const artifactId = `${body.message_id}:${body.seq}`;
426
+ await pool.query(
427
+ `INSERT INTO planning_artifacts
428
+ (id, world_id, session_id, type, title, body)
429
+ VALUES ($1, $2, $3, $4, $5, $6)
430
+ ON CONFLICT (id) DO NOTHING`,
431
+ [artifactId, body.world_id, body.session_id, artifactType, title, input],
432
+ );
433
+ }
434
+ } catch (artifactErr) {
435
+ console.warn(
436
+ `[plan-chat-service] planning_artifacts extraction failed for message=${body.message_id} seq=${body.seq}: ${artifactErr?.message ?? artifactErr}`,
437
+ );
438
+ }
439
+ }
402
440
  } catch (err) {
403
441
  if (err && typeof err === 'object' && 'code' in err && err.code === '23505') {
404
442
  return send(res, 409, { error: 'duplicate', message: '(message_id, seq) already exists' });
@@ -634,6 +672,26 @@ export function createHandler({
634
672
  createWorld,
635
673
  destroyWorld,
636
674
  });
675
+ // H7 (Phase G) — back-fill crystallized_world_id to all planning_artifacts
676
+ // rows for this session. The status pill state machine (Phase E E4) reads
677
+ // this field; the editor view + diagrams viewer use it for the
678
+ // "View world →" CTA. Failure here is logged + swallowed — the
679
+ // crystallize itself already succeeded; back-fill is a best-effort
680
+ // sync that an operator can re-run via a CLI helper.
681
+ if (result.worldId) {
682
+ try {
683
+ await pool.query(
684
+ `UPDATE planning_artifacts
685
+ SET crystallized_world_id = $1, status = 'crystallized'
686
+ WHERE session_id = $2 AND status = 'open'`,
687
+ [result.worldId, body.session_id],
688
+ );
689
+ } catch (backfillErr) {
690
+ console.warn(
691
+ `[plan-chat-service] crystallize back-fill failed for session=${body.session_id}: ${backfillErr?.message ?? backfillErr}`,
692
+ );
693
+ }
694
+ }
637
695
  return send(res, 200, {
638
696
  ok: true,
639
697
  created_world_id: result.worldId,
@@ -649,6 +707,68 @@ export function createHandler({
649
707
  }
650
708
  }
651
709
 
710
+ // H4 (Phase G) — GET + PATCH /v1/artifacts/:id endpoint pair backing
711
+ // the SPA's editor-view round-trip. GET returns the artifact row;
712
+ // PATCH updates body (the JSON payload) + bumps updated_at via trigger.
713
+ // SCOPE_ID_RE on :id; bearer auth identical to the chunks endpoints.
714
+ async function handleGetArtifact(req, res, id) {
715
+ if (!checkAuth(req)) return unauthorized(res);
716
+ if (!SCOPE_ID_RE.test(id)) return badRequest(res, 'invalid artifact id');
717
+ try {
718
+ const result = await pool.query(
719
+ `SELECT id, world_id, session_id, type, title, body, status,
720
+ linear_issue_url, crystallized_world_id,
721
+ created_at, updated_at
722
+ FROM planning_artifacts WHERE id = $1`,
723
+ [id],
724
+ );
725
+ const row = result.rows[0];
726
+ if (!row) return send(res, 404, { error: 'not-found' });
727
+ return send(res, 200, row);
728
+ } catch (err) {
729
+ return send(res, 500, { error: 'query-failed', message: String(err?.message ?? err) });
730
+ }
731
+ }
732
+
733
+ async function handlePatchArtifact(req, res, id) {
734
+ if (!checkAuth(req)) return unauthorized(res);
735
+ if (!SCOPE_ID_RE.test(id)) return badRequest(res, 'invalid artifact id');
736
+ let body;
737
+ try {
738
+ body = await readJson(req);
739
+ } catch {
740
+ return badRequest(res, 'malformed JSON body');
741
+ }
742
+ const sets = [];
743
+ const values = [id];
744
+ if (body.body !== undefined) {
745
+ values.push(body.body);
746
+ sets.push(`body = $${values.length}::jsonb`);
747
+ }
748
+ if (typeof body.title === 'string') {
749
+ values.push(body.title.slice(0, 200));
750
+ sets.push(`title = $${values.length}`);
751
+ }
752
+ if (typeof body.status === 'string' && ['open', 'crystallized', 'failed', 'archived'].includes(body.status)) {
753
+ values.push(body.status);
754
+ sets.push(`status = $${values.length}`);
755
+ }
756
+ if (sets.length === 0) {
757
+ return badRequest(res, 'no patchable fields supplied (body | title | status)');
758
+ }
759
+ try {
760
+ const result = await pool.query(
761
+ `UPDATE planning_artifacts SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
762
+ values,
763
+ );
764
+ const row = result.rows[0];
765
+ if (!row) return send(res, 404, { error: 'not-found' });
766
+ return send(res, 200, row);
767
+ } catch (err) {
768
+ return send(res, 500, { error: 'update-failed', message: String(err?.message ?? err) });
769
+ }
770
+ }
771
+
652
772
  return async function handler(req, res) {
653
773
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
654
774
  if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
@@ -656,6 +776,14 @@ export function createHandler({
656
776
  if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
657
777
  if (req.method === 'GET' && url.pathname === '/v1/planning-sessions') return handleGetPlanningSessions(req, res, url);
658
778
  if (req.method === 'POST' && url.pathname === '/v1/crystallize') return handlePostCrystallize(req, res);
779
+ // H4 — /v1/artifacts/:id pair
780
+ const artifactMatch = /^\/v1\/artifacts\/([^/]+)$/.exec(url.pathname);
781
+ if (artifactMatch) {
782
+ const id = decodeURIComponent(artifactMatch[1]);
783
+ if (req.method === 'GET') return handleGetArtifact(req, res, id);
784
+ if (req.method === 'PATCH') return handlePatchArtifact(req, res, id);
785
+ return send(res, 405, { error: 'method-not-allowed' });
786
+ }
659
787
  return send(res, 404, { error: 'not-found' });
660
788
  };
661
789
  }