Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
185 lines
6.2 KiB
JavaScript
185 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// S4: Pi abort once — API abort works; client reducer is idempotent (static + mock API).
|
|
// 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_S4_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
|
const ACTOR_ID = process.env.MNOTE_S4_ACTOR_ID || "s4-abort-admin";
|
|
const AUTH = process.env.MNOTE_S4_AUTH || "Bearer s4-abort";
|
|
const ROOT =
|
|
process.env.MNOTE_S4_ROOT ||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s4-abort-"));
|
|
|
|
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 seedWorkspace(rootUri, rootPath) {
|
|
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: `local-ws:${ACTOR_ID}:s4`,
|
|
workspace_name: "S4 abort",
|
|
root_uri: rootUri,
|
|
root_path: rootPath,
|
|
source_kind: "local_folder",
|
|
permission: "write",
|
|
capabilities: ["ai"],
|
|
grant_source: "s4_abort_smoke",
|
|
grant_created_by: ACTOR_ID,
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
|
throw new Error(
|
|
"S4 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)}`,
|
|
);
|
|
}
|
|
|
|
function staticClientChecks() {
|
|
const runtimePath = path.join(
|
|
__dirname,
|
|
"..",
|
|
"rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js",
|
|
);
|
|
const src = fs.readFileSync(runtimePath, "utf8");
|
|
assert(
|
|
src.includes("assistant_abort") && src.includes("STATE_ABORTED"),
|
|
"client must handle assistant_abort → STATE_ABORTED",
|
|
);
|
|
assert(
|
|
src.includes("abort is idempotent") ||
|
|
(src.includes("runtimeStatus === STATE_ABORTED") && src.includes("assistant_abort")),
|
|
"client reducer must short-circuit second assistant_abort",
|
|
);
|
|
// Optimistic single apply before fire-and-forget ABORT.
|
|
assert(
|
|
/function abortPrompt[\s\S]*?applyPiRunEvent\(\{\s*type:\s*'assistant_abort'/.test(src) ||
|
|
/function abortPrompt[\s\S]*?applyPiRunEvent\(\{ type: 'assistant_abort'/.test(src),
|
|
"abortPrompt must optimistically apply assistant_abort once",
|
|
);
|
|
assert(
|
|
/function abortPrompt[\s\S]*?fetch\(API\.ABORT/.test(src) ||
|
|
/function abortPrompt[\s\S]*?API\.ABORT/.test(src),
|
|
"abortPrompt must still call ABORT API",
|
|
);
|
|
console.log(" [static] client abort once + idempotent reducer OK");
|
|
}
|
|
|
|
async function main() {
|
|
staticClientChecks();
|
|
|
|
fs.mkdirSync(ROOT, { recursive: true });
|
|
fs.writeFileSync(path.join(ROOT, "s4.md"), "# S4 abort\n", "utf8");
|
|
const rootUri = `file://${ROOT}`;
|
|
|
|
console.log(`\n🧪 S4 Pi abort idempotent (base=${BASE}, actor=${ACTOR_ID})\n`);
|
|
|
|
await seedWorkspace(rootUri, ROOT);
|
|
|
|
const status = await fetchJson(`${BASE}/api/page-ai/pi/status`);
|
|
assert(status.status === 200 && status.body.ok === true, `status: ${status.status}`);
|
|
if (status.body.runtimeMode && status.body.runtimeMode !== "mock") {
|
|
console.log(` ⚠ runtimeMode=${status.body.runtimeMode} (prefer mock for this smoke)`);
|
|
}
|
|
|
|
const sessionId = `pi_lab_s4_${Date.now()}`;
|
|
const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
sessionId,
|
|
rootUri,
|
|
workspaceId: `local-ws:${ACTOR_ID}:s4`,
|
|
pagePath: "s4.md",
|
|
pageTitle: "S4 abort",
|
|
}),
|
|
});
|
|
assert(start.status === 200 && start.body.ok === true, `start: ${start.status} ${JSON.stringify(start.body)}`);
|
|
const sid = (start.body.session && start.body.session.sessionId) || sessionId;
|
|
console.log(` started ${sid}`);
|
|
|
|
// Send may stream; abort immediately after.
|
|
const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
sessionId: sid,
|
|
message: "S4 long answer please keep streaming",
|
|
rootUri,
|
|
}),
|
|
});
|
|
// Mock may accept or complete quickly — either is fine before abort.
|
|
assert(
|
|
send.status === 200 || send.status === 409 || send.status === 400,
|
|
`send unexpected: ${send.status} ${JSON.stringify(send.body)}`,
|
|
);
|
|
console.log(` send status=${send.status}`);
|
|
|
|
const abort1 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId: sid }),
|
|
});
|
|
assert(
|
|
abort1.status === 200 && abort1.body.ok === true && abort1.body.aborted === true,
|
|
`abort1: ${abort1.status} ${JSON.stringify(abort1.body)}`,
|
|
);
|
|
assert(abort1.body.stopReason === "aborted" || abort1.body.aborted === true, "abort1 stopReason");
|
|
console.log(" abort1 ok");
|
|
|
|
// Second abort must still succeed (idempotent server) without double-error.
|
|
const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId: sid }),
|
|
});
|
|
assert(
|
|
abort2.status === 200 && abort2.body.ok === true && abort2.body.aborted === true,
|
|
`abort2 must be idempotent: ${abort2.status} ${JSON.stringify(abort2.body)}`,
|
|
);
|
|
console.log(" abort2 idempotent ok");
|
|
|
|
console.log("\n✅ S4 Pi abort idempotent smoke passed\n");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("\n❌ S4 smoke failed:", err.message || err);
|
|
process.exit(1);
|
|
});
|