#!/usr/bin/env node // S6: Pi session resume/history from control-plane (seedAiRuntime + hydrate). // Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock. "use strict"; const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const BASE = process.env.MNOTE_S6_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; const ACTOR_ID = process.env.MNOTE_S6_ACTOR_ID || "s6-hydrate-admin"; const AUTH = process.env.MNOTE_S6_AUTH || "Bearer s6-hydrate"; const ROOT = process.env.MNOTE_S6_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s6-hydrate-")); const STAMP = Date.now(); function assert(condition, message) { if (!condition) throw new Error(message); } async function fetchJson(url, options = {}) { const headers = { "Content-Type": "application/json", Authorization: AUTH, "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "admin", ...(options.headers || {}), }; const res = await fetch(url, { ...options, headers }); const text = await res.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } return { status: res.status, body }; } async function main() { fs.mkdirSync(ROOT, { recursive: true }); const pagePath = `s6-hydrate-${STAMP}.md`; fs.writeFileSync(path.join(ROOT, pagePath), "# S6 hydrate\n", "utf8"); const rootUri = `file://${ROOT}`; const workspaceId = `local-ws:${ACTOR_ID}:s6`; const sessionId = `pi_lab_s6_${STAMP}`; const runId = `pi_run_${sessionId}`; const piSessionDir = path.join(ROOT, "pi-session"); const piSessionFile = path.join(piSessionDir, `${STAMP}_session.jsonl`); fs.mkdirSync(piSessionDir, { recursive: true }); const historyText = `S6_HYDRATE_REPLY_${STAMP}`; fs.writeFileSync( piSessionFile, [ JSON.stringify({ id: "u1", type: "message", message: { role: "user", content: "hydrate me" }, seq: 1, }), JSON.stringify({ id: "a1", parentId: "u1", type: "message", message: { role: "assistant", content: [{ type: "text", text: historyText }], }, seq: 2, }), ].join("\n") + "\n", "utf8", ); console.log(`\n🧪 S6 Pi resume/history hydrate (base=${BASE}, session=${sessionId})\n`); const seed = await fetchJson(`${BASE}/api/dev/seed`, { method: "POST", body: JSON.stringify({ seeds: [ { kind: "setupWorkspace", user_id: ACTOR_ID, email: `${ACTOR_ID}@example.com`, username: ACTOR_ID, display_name: ACTOR_ID, role: "admin", password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", workspace_id: workspaceId, workspace_name: "S6 hydrate", root_uri: rootUri, root_path: ROOT, source_kind: "local_folder", permission: "write", capabilities: ["ai"], grant_source: "s6_hydrate_smoke", grant_created_by: ACTOR_ID, }, { kind: "seedAiRuntime", user_id: ACTOR_ID, workspace_id: workspaceId, document_id: pagePath, session_id: sessionId, run_id: runId, title: "S6 hydrate session", profile: "pi_lab", acp_runtime: "pi", status: "runtime_running", runtime_json: { runtimeMode: "mock", rootUri, workspaceId, pagePath, pageTitle: "S6 hydrate", modelProvider: "omniroute", modelId: "pi-fast", thinkingLevel: "medium", piSessionDir, piSessionFile, messageCount: 2, }, payload_json: { message: "s6 hydrate" }, events: [ { eventType: "user_prompt", payloadJson: { message: "hydrate me" }, }, { eventType: "session_seeded", payloadJson: { source: "s6_smoke" }, }, ], }, ], }), }); if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { throw new Error( "S6 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", ); } assert( seed.status === 200 && seed.body.ok === true, `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, ); console.log(" seeded workspace + ai runtime"); // list_sessions should surface the seeded Pi Lab run from control-plane. const listed = await fetchJson( `${BASE}/api/page-ai/pi/sessions?workspaceId=${encodeURIComponent(workspaceId)}&limit=50`, ); assert(listed.status === 200 && listed.body.ok === true, `list: ${listed.status} ${JSON.stringify(listed.body)}`); assert( String(listed.body.schema) === "mnote.page_ai_pi.list_sessions.v1", `list schema: ${listed.body.schema}`, ); const sessions = Array.isArray(listed.body.sessions) ? listed.body.sessions : []; const found = sessions.find((s) => String(s.sessionId) === sessionId); assert(found, `list_sessions missing ${sessionId}: ${JSON.stringify(sessions).slice(0, 500)}`); console.log(` list_sessions includes ${sessionId} status=${found.status}`); // get_session_history should return the control-plane run (even without hot cache). const history = await fetchJson(`${BASE}/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}`); assert( history.status === 200 && history.body.ok !== false, `history: ${history.status} ${JSON.stringify(history.body).slice(0, 400)}`, ); // Accept either structured history messages or run metadata with runtime json. const bodyText = JSON.stringify(history.body); assert( bodyText.includes(sessionId) || bodyText.includes(runId) || bodyText.includes("hydrate") || bodyText.includes(historyText) || bodyText.includes("pi_lab"), `history payload should identify session: ${bodyText.slice(0, 500)}`, ); console.log(" get_session_history ok"); // Cold path: abort uses get_session_for_context → hydrate_session_from_control_plane. // Session is NOT in process hot cache after seed-only; hydrate must restore it. const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { method: "POST", body: JSON.stringify({ sessionId }), }); assert( abort.status === 200 && abort.body.ok === true && abort.body.aborted === true, `hydrate via abort failed (session not restored from control-plane): ${abort.status} ${JSON.stringify(abort.body)}`, ); console.log(" hydrate via abort: session restored from control-plane"); // Second operation after hydrate still works (hot cache now warm). const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { method: "POST", body: JSON.stringify({ sessionId }), }); assert( abort2.status === 200 && abort2.body.ok === true, `warm abort: ${abort2.status} ${JSON.stringify(abort2.body)}`, ); console.log(" warm-cache abort ok"); console.log("\n✅ S6 Pi resume/history hydrate smoke passed\n"); } main().catch((err) => { console.error("\n❌ S6 smoke failed:", err.message || err); process.exit(1); });