@pleri/olam-cli 0.1.186 → 0.1.188
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/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 +406 -22
- 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/create.d.ts.map +1 -1
- package/dist/commands/create.js +4 -0
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/install.js +2 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +12 -0
- package/dist/commands/services.js.map +1 -1
- package/dist/commands/setup.js +1 -1
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +4 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/image-digests.json +8 -8
- package/dist/index.js +788 -368
- 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/mcp-server.js +95 -27
- 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/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-service.mjs +216 -0
- package/host-cp/src/pr-cache.mjs +11 -2
- package/host-cp/src/server.mjs +36 -20
- package/package.json +1 -1
|
@@ -219,6 +219,17 @@ function validateChunkInput(body) {
|
|
|
219
219
|
return null;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// P1 — promote idempotency window. Keyed by chunk_id; value is
|
|
223
|
+
// { worldId, specPath, promoteId, createdAt }. Entries expire after
|
|
224
|
+
// PROMOTE_DEDUPE_TTL_MS so a genuine re-promote (after operator intent)
|
|
225
|
+
// is allowed rather than silently re-routing to the original world.
|
|
226
|
+
// In-memory only: a process restart clears the map. For v1 this is
|
|
227
|
+
// sufficient — the same Linear-retry window (seconds) is far shorter
|
|
228
|
+
// than the TTL (30s). Persistent dedup (across restarts / multi-replica)
|
|
229
|
+
// is deferred to U2.
|
|
230
|
+
const PROMOTE_DEDUPE_TTL_MS = 30_000;
|
|
231
|
+
const _promoteDedupe = new Map(); // chunk_id → { worldId, specPath, promoteId, createdAt }
|
|
232
|
+
|
|
222
233
|
/**
|
|
223
234
|
* Build the HTTP request handler. Pure factory — easy to test against a
|
|
224
235
|
* stubbed pool. Production callers pass a real pg.Pool.
|
|
@@ -240,6 +251,17 @@ export function createHandler({
|
|
|
240
251
|
shapeDebugLog,
|
|
241
252
|
createWorld,
|
|
242
253
|
destroyWorld,
|
|
254
|
+
/**
|
|
255
|
+
* P1 — injectable dispatchTask callback. Accepts { worldId, containerName,
|
|
256
|
+
* task, tier } and dispatches the task to the newly-created world.
|
|
257
|
+
* Production callers wire in autoDispatchTask from @olam/core; tests inject
|
|
258
|
+
* a stub. When omitted, the promote endpoint returns 501.
|
|
259
|
+
*
|
|
260
|
+
* Tier routing: the caller is responsible for reading compute.default
|
|
261
|
+
* from the workspace config and passing it as opts.tier when applicable.
|
|
262
|
+
* This keeps plan-chat-service free from workspace-config coupling.
|
|
263
|
+
*/
|
|
264
|
+
dispatchTask,
|
|
243
265
|
/** B4 — optional override for tests. When supplied, replaces principalFromBearer
|
|
244
266
|
* so the test harness can inject a hardcoded server-resolved actor_id and verify
|
|
245
267
|
* the mismatch guard. Production callers omit this. */
|
|
@@ -248,6 +270,11 @@ export function createHandler({
|
|
|
248
270
|
* default per-bearer rate limiter (60 req/min). Tests inject a stub with
|
|
249
271
|
* lower capacity to exercise the 429 path quickly. Production callers omit. */
|
|
250
272
|
rateLimiter,
|
|
273
|
+
/**
|
|
274
|
+
* P1 — override the dedupe map for tests. Allows tests to pre-populate
|
|
275
|
+
* or inspect the idempotency store. Production callers omit this.
|
|
276
|
+
*/
|
|
277
|
+
_promoteDedupe: promoteDedupe = _promoteDedupe,
|
|
251
278
|
}) {
|
|
252
279
|
if (!pool) throw new Error('createHandler: { pool } required');
|
|
253
280
|
if (typeof bearer !== 'string' || bearer.length === 0) {
|
|
@@ -837,6 +864,181 @@ export function createHandler({
|
|
|
837
864
|
}
|
|
838
865
|
}
|
|
839
866
|
|
|
867
|
+
// P1 — POST /v1/prototypes/:id/promote
|
|
868
|
+
//
|
|
869
|
+
// Accepts a prototype chunk_id in the URL path. Validates the chunk exists
|
|
870
|
+
// and is a prototype-shaped tool_use chunk, then dispatches a docker world
|
|
871
|
+
// to implement it. Returns 202 Accepted with { worldId, promoteId, specPath }
|
|
872
|
+
// before the docker world finishes booting (the boot is async).
|
|
873
|
+
//
|
|
874
|
+
// Idempotency: same chunk_id within PROMOTE_DEDUPE_TTL_MS returns
|
|
875
|
+
// 202 { action: "deduplicated", worldId } without creating a second world.
|
|
876
|
+
// Rationale: Linear retries on non-2xx; a 409 would trigger infinite retries.
|
|
877
|
+
// 202 + action body is the correct idempotent-promote semantic.
|
|
878
|
+
//
|
|
879
|
+
// Tier routing: the injected `dispatchTask` callback owns tier selection.
|
|
880
|
+
// The caller (production: WorldManager glue in server.mjs) passes the
|
|
881
|
+
// workspace's `compute.default` tier as part of the dispatchTask closure.
|
|
882
|
+
// This keeps plan-chat-service free from workspace config coupling.
|
|
883
|
+
async function handlePostPrototypePromote(req, res, chunkId) {
|
|
884
|
+
if (!checkAuth(req)) return unauthorized(res);
|
|
885
|
+
|
|
886
|
+
// createWorld + dispatchTask must both be wired. If either is absent,
|
|
887
|
+
// surface a clear 501 rather than crashing (mirrors crystallize guard).
|
|
888
|
+
if (typeof createWorld !== 'function' || typeof dispatchTask !== 'function') {
|
|
889
|
+
return send(res, 501, {
|
|
890
|
+
error: 'promote-not-wired',
|
|
891
|
+
message: 'createWorld and dispatchTask callbacks are required',
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Idempotency: check dedupe map before any DB reads.
|
|
896
|
+
const now = Date.now();
|
|
897
|
+
const existing = promoteDedupe.get(chunkId);
|
|
898
|
+
if (existing && now - existing.createdAt < PROMOTE_DEDUPE_TTL_MS) {
|
|
899
|
+
return send(res, 202, {
|
|
900
|
+
action: 'deduplicated',
|
|
901
|
+
worldId: existing.worldId,
|
|
902
|
+
promoteId: existing.promoteId,
|
|
903
|
+
specPath: existing.specPath,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Parse optional body { targetWorldName?, specName? }
|
|
908
|
+
let body = {};
|
|
909
|
+
try {
|
|
910
|
+
body = await readJson(req);
|
|
911
|
+
} catch (err) {
|
|
912
|
+
return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Look up the chunk by id in the chunks table.
|
|
916
|
+
let chunk;
|
|
917
|
+
try {
|
|
918
|
+
const result = await pool.query(
|
|
919
|
+
`SELECT world_id, session_id, message_id, seq, actor_id, actor_type,
|
|
920
|
+
role, chunk, chunk_type
|
|
921
|
+
FROM chunks WHERE message_id = $1
|
|
922
|
+
LIMIT 1`,
|
|
923
|
+
[chunkId],
|
|
924
|
+
);
|
|
925
|
+
chunk = result.rows[0] ?? null;
|
|
926
|
+
} catch (err) {
|
|
927
|
+
return send(res, 500, {
|
|
928
|
+
error: 'query-failed',
|
|
929
|
+
message: String(err?.message ?? err),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (!chunk) {
|
|
934
|
+
return send(res, 404, {
|
|
935
|
+
error: 'chunk-not-found',
|
|
936
|
+
message: `No chunk found with id '${chunkId}'`,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Validate: must be a tool_use chunk with a prototype-shaped name.
|
|
941
|
+
if (chunk.chunk_type !== 'tool_use') {
|
|
942
|
+
return send(res, 400, {
|
|
943
|
+
error: 'invalid-chunk-type',
|
|
944
|
+
message: `chunk must have chunk_type='tool_use', got '${chunk.chunk_type}'`,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
let parsedChunk;
|
|
949
|
+
try {
|
|
950
|
+
parsedChunk = JSON.parse(chunk.chunk);
|
|
951
|
+
} catch {
|
|
952
|
+
return send(res, 400, {
|
|
953
|
+
error: 'invalid-chunk-json',
|
|
954
|
+
message: 'chunk content is not valid JSON',
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const toolName = typeof parsedChunk?.name === 'string' ? parsedChunk.name : '';
|
|
959
|
+
if (!toolName.includes('prototype')) {
|
|
960
|
+
return send(res, 400, {
|
|
961
|
+
error: 'not-a-prototype-chunk',
|
|
962
|
+
message: `chunk tool name '${toolName}' does not match prototype shape (name must include 'prototype')`,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Derive world name and spec path from the chunk's input.
|
|
967
|
+
const input = parsedChunk.input ?? {};
|
|
968
|
+
const rawTitle =
|
|
969
|
+
(typeof body.specName === 'string' && body.specName.length > 0
|
|
970
|
+
? body.specName
|
|
971
|
+
: typeof input.title === 'string' && input.title.length > 0
|
|
972
|
+
? input.title
|
|
973
|
+
: 'prototype')
|
|
974
|
+
.toLowerCase()
|
|
975
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
976
|
+
.replace(/^-+|-+$/g, '')
|
|
977
|
+
.slice(0, 40) || 'prototype';
|
|
978
|
+
|
|
979
|
+
const worldName =
|
|
980
|
+
typeof body.targetWorldName === 'string' && body.targetWorldName.length > 0
|
|
981
|
+
? body.targetWorldName
|
|
982
|
+
: `proto-${rawTitle}`;
|
|
983
|
+
|
|
984
|
+
const specPath = `design-specs/${rawTitle}-v1.spec.html`;
|
|
985
|
+
const specSource =
|
|
986
|
+
typeof input.source === 'string' ? input.source : JSON.stringify(input);
|
|
987
|
+
|
|
988
|
+
// Build the dispatch prompt: embed the spec source + instruct the agent
|
|
989
|
+
// to write it at specPath on first tool use. Per D3: the agent inside
|
|
990
|
+
// the world writes the file — host-cp does not need docker mount access.
|
|
991
|
+
const dispatchPrompt = [
|
|
992
|
+
`## Prototype implementation task`,
|
|
993
|
+
``,
|
|
994
|
+
`A prototype has been promoted for implementation. Write the spec file`,
|
|
995
|
+
`at \`${specPath}\` as your first action, then implement it production-ready.`,
|
|
996
|
+
``,
|
|
997
|
+
`### Spec source (write verbatim to ${specPath})`,
|
|
998
|
+
``,
|
|
999
|
+
`\`\`\`html`,
|
|
1000
|
+
specSource,
|
|
1001
|
+
`\`\`\``,
|
|
1002
|
+
``,
|
|
1003
|
+
`After writing the spec file, implement it fully — components, styles,`,
|
|
1004
|
+
`tests, and documentation. The spec is the source of truth.`,
|
|
1005
|
+
`If a file already exists at \`${specPath}\`, surface a conflict chunk`,
|
|
1006
|
+
`to the operator before overwriting (per D4 of the promote endpoint plan).`,
|
|
1007
|
+
].join('\n');
|
|
1008
|
+
|
|
1009
|
+
// Create the world.
|
|
1010
|
+
let worldId;
|
|
1011
|
+
try {
|
|
1012
|
+
const world = await createWorld({ name: worldName });
|
|
1013
|
+
worldId = world.id;
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
return send(res, 500, {
|
|
1016
|
+
error: 'world-creation-failed',
|
|
1017
|
+
message: String(err?.message ?? err),
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Dispatch the task to the new world.
|
|
1022
|
+
try {
|
|
1023
|
+
await dispatchTask({ worldId, task: dispatchPrompt });
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
// The world was created but dispatch failed. Surface 502 so the caller
|
|
1026
|
+
// can retry dispatch manually via `olam dispatch <id> "<task>"`.
|
|
1027
|
+
return send(res, 502, {
|
|
1028
|
+
error: 'dispatch-failed',
|
|
1029
|
+
worldId,
|
|
1030
|
+
message: String(err?.message ?? err),
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const promoteId = `promote-${chunkId}-${worldId}`;
|
|
1035
|
+
|
|
1036
|
+
// Record in dedupe map before returning.
|
|
1037
|
+
promoteDedupe.set(chunkId, { worldId, specPath, promoteId, createdAt: now });
|
|
1038
|
+
|
|
1039
|
+
return send(res, 202, { worldId, promoteId, specPath, status: 'dispatched' });
|
|
1040
|
+
}
|
|
1041
|
+
|
|
840
1042
|
return async function handler(req, res) {
|
|
841
1043
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
842
1044
|
if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
|
|
@@ -859,6 +1061,16 @@ export function createHandler({
|
|
|
859
1061
|
if (req.method === 'PATCH') return handlePatchArtifact(req, res, id);
|
|
860
1062
|
return send(res, 405, { error: 'method-not-allowed' });
|
|
861
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
// P1 — /v1/prototypes/:id/promote
|
|
1066
|
+
// startsWith + endsWith guard prevents shadowing a future /v1/prototypes
|
|
1067
|
+
// GET list route (per risk note in the plan). The match is exact.
|
|
1068
|
+
const promoteMatch = /^\/v1\/prototypes\/([^/]+)\/promote$/.exec(url.pathname);
|
|
1069
|
+
if (promoteMatch && req.method === 'POST') {
|
|
1070
|
+
const chunkId = decodeURIComponent(promoteMatch[1]);
|
|
1071
|
+
return handlePostPrototypePromote(req, res, chunkId);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
862
1074
|
return send(res, 404, { error: 'not-found' });
|
|
863
1075
|
};
|
|
864
1076
|
}
|
|
@@ -889,8 +1101,12 @@ export async function startService(opts = {}) {
|
|
|
889
1101
|
electricUrl,
|
|
890
1102
|
shapeDebug: opts.shapeDebug,
|
|
891
1103
|
shapeDebugLog: opts.shapeDebugLog,
|
|
1104
|
+
createWorld: opts.createWorld,
|
|
1105
|
+
destroyWorld: opts.destroyWorld,
|
|
1106
|
+
dispatchTask: opts.dispatchTask,
|
|
892
1107
|
resolveActor: opts.resolveActor,
|
|
893
1108
|
rateLimiter: opts.rateLimiter,
|
|
1109
|
+
_promoteDedupe: opts._promoteDedupe,
|
|
894
1110
|
});
|
|
895
1111
|
const server = http.createServer((req, res) => {
|
|
896
1112
|
handler(req, res).catch((err) => {
|
package/host-cp/src/pr-cache.mjs
CHANGED
|
@@ -127,7 +127,7 @@ async function fetchPrData(prUrl, getToken) {
|
|
|
127
127
|
/**
|
|
128
128
|
* Create a PR data cache with TTL and concurrent-fetch coalescing.
|
|
129
129
|
*
|
|
130
|
-
* @returns {{ getPr: (prUrl: string, getToken: () => Promise<string|null>) => Promise<PrData|null
|
|
130
|
+
* @returns {{ getPr: (prUrl: string, getToken: () => Promise<string|null>) => Promise<PrData|null>, deletePr: (prUrl: string) => void }}
|
|
131
131
|
*/
|
|
132
132
|
export function createPrCache() {
|
|
133
133
|
/** @type {Map<string, PrCacheEntry>} */
|
|
@@ -197,5 +197,14 @@ export function createPrCache() {
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Evict a PR entry from the cache (call on world destroy).
|
|
202
|
+
*
|
|
203
|
+
* @param {string} prUrl
|
|
204
|
+
*/
|
|
205
|
+
function deletePr(prUrl) {
|
|
206
|
+
cache.delete(prUrl);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { getPr, deletePr };
|
|
201
210
|
}
|
package/host-cp/src/server.mjs
CHANGED
|
@@ -510,6 +510,9 @@ const prPoller = createPrMergePoller({
|
|
|
510
510
|
WORLDS = next;
|
|
511
511
|
persistRegistry();
|
|
512
512
|
}
|
|
513
|
+
const prState = prStateStore.get(worldId);
|
|
514
|
+
if (prState?.pr_url) prCache.deletePr(prState.pr_url);
|
|
515
|
+
progressCache.delete(worldId);
|
|
513
516
|
},
|
|
514
517
|
pollIntervalMs: PR_POLL_INTERVAL_MS,
|
|
515
518
|
gracePeriodMs: MERGE_GRACE_MS,
|
|
@@ -1366,6 +1369,9 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
1366
1369
|
WORLDS = next;
|
|
1367
1370
|
persistRegistry();
|
|
1368
1371
|
}
|
|
1372
|
+
const prStateForDelete = prStateStore.get(id);
|
|
1373
|
+
if (prStateForDelete?.pr_url) prCache.deletePr(prStateForDelete.pr_url);
|
|
1374
|
+
progressCache.delete(id);
|
|
1369
1375
|
return jsonReply(res, 200, { worlds: WORLDS });
|
|
1370
1376
|
}
|
|
1371
1377
|
|
|
@@ -1526,7 +1532,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
1526
1532
|
if (url.pathname === '/api/repos' && req.method === 'GET') {
|
|
1527
1533
|
const config = loadGlobalConfig();
|
|
1528
1534
|
if ('error' in config) {
|
|
1529
|
-
return jsonReply(res, 500, { error: config.error });
|
|
1535
|
+
return jsonReply(res, 500, { error: config.error, message: config.error });
|
|
1530
1536
|
}
|
|
1531
1537
|
return jsonReply(res, 200, { repos: config.repos });
|
|
1532
1538
|
}
|
|
@@ -1536,7 +1542,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
1536
1542
|
if (url.pathname === '/api/runbooks' && req.method === 'GET') {
|
|
1537
1543
|
const config = loadGlobalConfig();
|
|
1538
1544
|
if ('error' in config) {
|
|
1539
|
-
return jsonReply(res, 500, { error: config.error });
|
|
1545
|
+
return jsonReply(res, 500, { error: config.error, message: config.error });
|
|
1540
1546
|
}
|
|
1541
1547
|
return jsonReply(res, 200, { runbooks: config.runbooks });
|
|
1542
1548
|
}
|
|
@@ -1679,7 +1685,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
1679
1685
|
const body = await readRequestBody(req);
|
|
1680
1686
|
const provider = body && typeof body === 'object' && body.provider ? String(body.provider) : 'claude';
|
|
1681
1687
|
const label = body && typeof body === 'object' && body.label ? String(body.label) : '';
|
|
1682
|
-
if (!label) return jsonReply(res, 400, { error: 'label_required' });
|
|
1688
|
+
if (!label) return jsonReply(res, 400, { error: 'label_required', message: 'label field is required' });
|
|
1683
1689
|
const upstream = await authServiceFetch('POST', '/credentials/add', { provider, label });
|
|
1684
1690
|
const data = await upstream.json();
|
|
1685
1691
|
return jsonReply(res, upstream.status, data);
|
|
@@ -1996,9 +2002,9 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
1996
2002
|
req.on('data', (c) => { body += c; });
|
|
1997
2003
|
req.on('end', () => {
|
|
1998
2004
|
let parsed;
|
|
1999
|
-
try { parsed = JSON.parse(body || '{}'); } catch { return jsonReply(res, 400, { error: 'invalid_json' }); }
|
|
2005
|
+
try { parsed = JSON.parse(body || '{}'); } catch (e) { return jsonReply(res, 400, { error: 'invalid_json', message: e.message }); }
|
|
2000
2006
|
const { worldId, prUrl, prNumber, prRepo } = parsed ?? {};
|
|
2001
|
-
if (!worldId || !prUrl) return jsonReply(res, 400, { error: 'worldId and prUrl required' });
|
|
2007
|
+
if (!worldId || !prUrl) return jsonReply(res, 400, { error: 'missing_fields', message: 'worldId and prUrl required' });
|
|
2002
2008
|
prStateStore.set(worldId, {
|
|
2003
2009
|
pr_url: prUrl,
|
|
2004
2010
|
pr_number: prNumber ?? null,
|
|
@@ -2023,9 +2029,9 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2023
2029
|
req.on('data', (c) => { body += c; });
|
|
2024
2030
|
req.on('end', () => {
|
|
2025
2031
|
let parsed;
|
|
2026
|
-
try { parsed = JSON.parse(body || '{}'); } catch { return jsonReply(res, 400, { error: 'invalid_json' }); }
|
|
2032
|
+
try { parsed = JSON.parse(body || '{}'); } catch (e) { return jsonReply(res, 400, { error: 'invalid_json', message: e.message }); }
|
|
2027
2033
|
const existing = prStateStore.get(worldId);
|
|
2028
|
-
if (!existing) return jsonReply(res, 404, { error: 'world not in PR state store' });
|
|
2034
|
+
if (!existing) return jsonReply(res, 404, { error: 'not_found', message: 'world not in PR state store' });
|
|
2029
2035
|
const updates = {};
|
|
2030
2036
|
if (typeof parsed.autoDestroyOnMerge === 'boolean') updates.auto_destroy_on_merge = parsed.autoDestroyOnMerge;
|
|
2031
2037
|
prStateStore.set(worldId, updates);
|
|
@@ -2108,7 +2114,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2108
2114
|
const id = decodeURIComponent(planConvMatch[1]);
|
|
2109
2115
|
try {
|
|
2110
2116
|
const conv = planOrchestrator.getConversation(id);
|
|
2111
|
-
if (!conv) return jsonReply(res, 404, { error: 'not_found', id });
|
|
2117
|
+
if (!conv) return jsonReply(res, 404, { error: 'not_found', id, message: 'conversation not found' });
|
|
2112
2118
|
// Return persisted turns alongside conversation metadata.
|
|
2113
2119
|
const turns = planOrchestrator.getTurns(id);
|
|
2114
2120
|
return jsonReply(res, 200, { ...conv, turns });
|
|
@@ -2128,7 +2134,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2128
2134
|
const content = (body && typeof body === 'object' && typeof body.content === 'string')
|
|
2129
2135
|
? body.content.trim()
|
|
2130
2136
|
: '';
|
|
2131
|
-
if (!content) return jsonReply(res, 400, { error: 'content_required' });
|
|
2137
|
+
if (!content) return jsonReply(res, 400, { error: 'content_required', message: 'turn content must be a non-empty string' });
|
|
2132
2138
|
const personaOverride = (body && typeof body === 'object' && typeof body.personaOverride === 'string')
|
|
2133
2139
|
? body.personaOverride
|
|
2134
2140
|
: undefined;
|
|
@@ -2139,7 +2145,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2139
2145
|
const result = await planOrchestrator.submitTurn({ conversationId, content, personaOverride, mentionedPersonas });
|
|
2140
2146
|
return jsonReply(res, 202, result);
|
|
2141
2147
|
} catch (err) {
|
|
2142
|
-
if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId });
|
|
2148
|
+
if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
|
|
2143
2149
|
return jsonReply(res, 500, { error: 'submit_failed', message: err.message });
|
|
2144
2150
|
}
|
|
2145
2151
|
}
|
|
@@ -2154,7 +2160,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2154
2160
|
}
|
|
2155
2161
|
const toPersona = body?.toPersona;
|
|
2156
2162
|
if (typeof toPersona !== 'string' || !toPersona) {
|
|
2157
|
-
return jsonReply(res, 400, { error: 'toPersona_required' });
|
|
2163
|
+
return jsonReply(res, 400, { error: 'toPersona_required', message: 'toPersona field is required' });
|
|
2158
2164
|
}
|
|
2159
2165
|
const mode = ['full', 'distilled', 'quoted'].includes(body?.mode) ? body.mode : 'full';
|
|
2160
2166
|
const selectedTurnIds = Array.isArray(body?.selectedTurnIds) ? body.selectedTurnIds : [];
|
|
@@ -2164,7 +2170,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2164
2170
|
});
|
|
2165
2171
|
return jsonReply(res, 200, result);
|
|
2166
2172
|
} catch (err) {
|
|
2167
|
-
if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId });
|
|
2173
|
+
if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
|
|
2168
2174
|
return jsonReply(res, 500, { error: 'handoff_failed', message: err.message });
|
|
2169
2175
|
}
|
|
2170
2176
|
}
|
|
@@ -2174,7 +2180,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2174
2180
|
if (!await requirePlanCredential(res)) return;
|
|
2175
2181
|
const conversationId = decodeURIComponent(planStreamMatch[1]);
|
|
2176
2182
|
const conv = planOrchestrator.getConversation(conversationId);
|
|
2177
|
-
if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId });
|
|
2183
|
+
if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
|
|
2178
2184
|
|
|
2179
2185
|
res.writeHead(200, {
|
|
2180
2186
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
@@ -2214,7 +2220,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2214
2220
|
const conversationId = decodeURIComponent(planAgentInviteMatch[1]);
|
|
2215
2221
|
const personaId = decodeURIComponent(planAgentInviteMatch[2]);
|
|
2216
2222
|
const conv = planOrchestrator.getConversation(conversationId);
|
|
2217
|
-
if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId });
|
|
2223
|
+
if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
|
|
2218
2224
|
const agent = planOrchestrator.inviteLookout(conversationId, personaId);
|
|
2219
2225
|
return jsonReply(res, 200, agent);
|
|
2220
2226
|
}
|
|
@@ -2232,7 +2238,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2232
2238
|
if (typeof body?.muted === 'boolean') updates.muted = body.muted;
|
|
2233
2239
|
if (typeof body?.mode === 'string') updates.mode = body.mode;
|
|
2234
2240
|
const agent = planOrchestrator.updateLookout(conversationId, personaId, updates);
|
|
2235
|
-
if (!agent) return jsonReply(res, 404, { error: 'not_found' });
|
|
2241
|
+
if (!agent) return jsonReply(res, 404, { error: 'not_found', message: 'lookout agent not found' });
|
|
2236
2242
|
return jsonReply(res, 200, agent);
|
|
2237
2243
|
}
|
|
2238
2244
|
|
|
@@ -2253,7 +2259,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2253
2259
|
const changed = action === 'dismiss'
|
|
2254
2260
|
? planOrchestrator.dismissSignal(conversationId, signalId)
|
|
2255
2261
|
: planOrchestrator.useSignal(conversationId, signalId);
|
|
2256
|
-
if (!changed) return jsonReply(res, 404, { error: 'not_found' });
|
|
2262
|
+
if (!changed) return jsonReply(res, 404, { error: 'not_found', message: 'signal not found' });
|
|
2257
2263
|
return jsonReply(res, 200, { ok: true });
|
|
2258
2264
|
}
|
|
2259
2265
|
|
|
@@ -2717,7 +2723,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2717
2723
|
if (serversMatch && req.method === 'GET') {
|
|
2718
2724
|
const worldId = decodeURIComponent(serversMatch[1]);
|
|
2719
2725
|
if (!(worldId in WORLDS)) {
|
|
2720
|
-
return jsonReply(res, 404, { error: 'unknown_world', worldId });
|
|
2726
|
+
return jsonReply(res, 404, { error: 'unknown_world', worldId, message: 'world not in registry' });
|
|
2721
2727
|
}
|
|
2722
2728
|
return handleListServers(req, res, worldId);
|
|
2723
2729
|
}
|
|
@@ -2727,7 +2733,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2727
2733
|
const worldId = decodeURIComponent(bridgesMatch[1]);
|
|
2728
2734
|
const portSegment = bridgesMatch[3] ? parseInt(bridgesMatch[3], 10) : null;
|
|
2729
2735
|
if (!(worldId in WORLDS)) {
|
|
2730
|
-
return jsonReply(res, 404, { error: 'unknown_world', worldId });
|
|
2736
|
+
return jsonReply(res, 404, { error: 'unknown_world', worldId, message: 'world not in registry' });
|
|
2731
2737
|
}
|
|
2732
2738
|
return handleServerBridges(req, res, worldId, portSegment);
|
|
2733
2739
|
}
|
|
@@ -2798,7 +2804,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2798
2804
|
if (v1WorldsStatusMatch && req.method === 'GET') {
|
|
2799
2805
|
const worldId = decodeURIComponent(v1WorldsStatusMatch[1]);
|
|
2800
2806
|
if (!V1_WORLD_ID.test(worldId)) {
|
|
2801
|
-
return jsonReply(res, 400, { error: 'invalid_world_id', worldId });
|
|
2807
|
+
return jsonReply(res, 400, { error: 'invalid_world_id', worldId, message: 'worldId contains invalid characters' });
|
|
2802
2808
|
}
|
|
2803
2809
|
try {
|
|
2804
2810
|
const report = await hostCpEngine.getWorldStatus(worldId);
|
|
@@ -2816,7 +2822,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
|
|
|
2816
2822
|
if (v1WorldsLogsMatch && req.method === 'GET') {
|
|
2817
2823
|
const worldId = decodeURIComponent(v1WorldsLogsMatch[1]);
|
|
2818
2824
|
if (!V1_WORLD_ID.test(worldId)) {
|
|
2819
|
-
return jsonReply(res, 400, { error: 'invalid_world_id', worldId });
|
|
2825
|
+
return jsonReply(res, 400, { error: 'invalid_world_id', worldId, message: 'worldId contains invalid characters' });
|
|
2820
2826
|
}
|
|
2821
2827
|
const tailParam = url.searchParams.get('tail');
|
|
2822
2828
|
const followParam = url.searchParams.get('follow');
|
|
@@ -3466,6 +3472,7 @@ async function renderSpaShell(filePath, pathname) {
|
|
|
3466
3472
|
// block comment above (Phase E5 cutover).
|
|
3467
3473
|
void skipBootstrap; // wildcard invariant: always true; documents intent
|
|
3468
3474
|
html = html.replace(/<head>/i, `<head>\n ${bearerInjection}${cloudInjection}`);
|
|
3475
|
+
if (_spaCacheByKey.size > 10) _spaCacheByKey.clear();
|
|
3469
3476
|
_spaCacheByKey.set(cacheKey, html);
|
|
3470
3477
|
return html;
|
|
3471
3478
|
}
|
|
@@ -3660,6 +3667,15 @@ server.listen(PORT, '0.0.0.0', () => {
|
|
|
3660
3667
|
} else if (AUTH_SERVICE_URL) {
|
|
3661
3668
|
console.log(` auth-service=${AUTH_SERVICE_URL} (X-Olam-Secret configured)`);
|
|
3662
3669
|
}
|
|
3670
|
+
// Warn when only one of the two cloud env vars is set — the most common
|
|
3671
|
+
// misconfiguration that silently disables the Cloud toggle in the SPA.
|
|
3672
|
+
const hasCloudUrl = Boolean(process.env.OLAM_CLOUD_URL);
|
|
3673
|
+
const hasShowcasePw = Boolean(process.env.OLAM_SHOWCASE_PASSWORD);
|
|
3674
|
+
if (hasCloudUrl && !hasShowcasePw) {
|
|
3675
|
+
console.warn(' [cloud] OLAM_CLOUD_URL set but OLAM_SHOWCASE_PASSWORD missing — cloud_enabled will be false');
|
|
3676
|
+
} else if (hasShowcasePw && !hasCloudUrl) {
|
|
3677
|
+
console.warn(' [cloud] OLAM_SHOWCASE_PASSWORD set but OLAM_CLOUD_URL missing — cloud_enabled will be false');
|
|
3678
|
+
}
|
|
3663
3679
|
});
|
|
3664
3680
|
|
|
3665
3681
|
// Graceful shutdown so docker compose down → SIGTERM → flush + close.
|