@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.
- package/dist/agent-stream/driver-runner.js +13 -0
- package/dist/commands/auth.d.ts +22 -7
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +414 -46
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +45 -1
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/services.d.ts +39 -0
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +64 -9
- package/dist/commands/services.js.map +1 -1
- package/dist/from-manifest.d.ts +53 -0
- package/dist/from-manifest.d.ts.map +1 -0
- package/dist/from-manifest.js +95 -0
- package/dist/from-manifest.js.map +1 -0
- package/dist/image-digests.json +8 -8
- package/dist/index.js +911 -137
- package/dist/lib/auth-remote.d.ts +130 -0
- package/dist/lib/auth-remote.d.ts.map +1 -0
- package/dist/lib/auth-remote.js +307 -0
- package/dist/lib/auth-remote.js.map +1 -0
- package/dist/mcp-server.js +1487 -435
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
- 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/observability/ndjson-span-sink.mjs +52 -0
- package/host-cp/src/boot-reconciler.mjs +238 -0
- package/host-cp/src/linear-sync.mjs +43 -0
- package/host-cp/src/plan-chat-service.mjs +129 -1
- package/host-cp/src/port-bridge-manager.mjs +116 -10
- package/host-cp/src/server.mjs +121 -1
- package/host-cp/src/world-activity-tracker.mjs +392 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|