258 lines
7.9 KiB
JavaScript
258 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
// S7: agent target package on send + dirty write conflict gate (client static + API accept).
|
||
|
|
// 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_S7_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017";
|
||
|
|
const ACTOR_ID = process.env.MNOTE_S7_ACTOR_ID || "s7-target-admin";
|
||
|
|
const AUTH = process.env.MNOTE_S7_AUTH || "Bearer s7-target";
|
||
|
|
const ROOT =
|
||
|
|
process.env.MNOTE_S7_ROOT ||
|
||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s7-target-"));
|
||
|
|
|
||
|
|
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 };
|
||
|
|
}
|
||
|
|
|
||
|
|
function staticChecks() {
|
||
|
|
const piLab = path.join(
|
||
|
|
__dirname,
|
||
|
|
"..",
|
||
|
|
"rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js",
|
||
|
|
);
|
||
|
|
const src = fs.readFileSync(piLab, "utf8");
|
||
|
|
assert(src.includes("mnote.agent_target_package.v1"), "must build agent target package v1");
|
||
|
|
assert(
|
||
|
|
src.includes("buildPiLabAgentTargetPackage") || src.includes("targetPackage"),
|
||
|
|
"must attach targetPackage on send",
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
src.includes("assertPiLabTargetWritable") || src.includes("page_ai_target_buffer_not_writable"),
|
||
|
|
"must gate dirty buffer before send",
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
src.includes("writeRequiresCleanBuffer") || src.includes("fail_on_dirty_or_stale"),
|
||
|
|
"policy must require clean buffer / fail on dirty",
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
src.includes("targetPackage: targetPackage") || src.includes("targetPackage:targetPackage"),
|
||
|
|
"send body must include targetPackage",
|
||
|
|
);
|
||
|
|
|
||
|
|
const runtimeRs = path.join(
|
||
|
|
__dirname,
|
||
|
|
"..",
|
||
|
|
"rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs",
|
||
|
|
);
|
||
|
|
const rs = fs.readFileSync(runtimeRs, "utf8");
|
||
|
|
assert(
|
||
|
|
rs.includes("target_package") && rs.includes("PiLabSendRequest"),
|
||
|
|
"server PiLabSendRequest must accept target_package",
|
||
|
|
);
|
||
|
|
console.log(" [static] client target package + dirty gate + server accept field OK");
|
||
|
|
}
|
||
|
|
|
||
|
|
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}:s7`,
|
||
|
|
workspace_name: "S7 target",
|
||
|
|
root_uri: rootUri,
|
||
|
|
root_path: rootPath,
|
||
|
|
source_kind: "local_folder",
|
||
|
|
permission: "write",
|
||
|
|
capabilities: ["ai"],
|
||
|
|
grant_source: "s7_target_smoke",
|
||
|
|
grant_created_by: ACTOR_ID,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
|
||
|
|
throw new Error(
|
||
|
|
"S7 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)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
staticChecks();
|
||
|
|
|
||
|
|
fs.mkdirSync(ROOT, { recursive: true });
|
||
|
|
const pagePath = "s7.md";
|
||
|
|
fs.writeFileSync(path.join(ROOT, pagePath), "# S7 target package\n", "utf8");
|
||
|
|
const rootUri = `file://${ROOT}`;
|
||
|
|
const workspaceId = `local-ws:${ACTOR_ID}:s7`;
|
||
|
|
const documentId = `local-doc:${workspaceId}:${pagePath}`;
|
||
|
|
|
||
|
|
console.log(`\n🧪 S7 agent target package + dirty guard (base=${BASE})\n`);
|
||
|
|
|
||
|
|
await seedWorkspace(rootUri, ROOT);
|
||
|
|
|
||
|
|
const sessionId = `pi_lab_s7_${Date.now()}`;
|
||
|
|
const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
sessionId,
|
||
|
|
rootUri,
|
||
|
|
workspaceId,
|
||
|
|
pagePath,
|
||
|
|
pageTitle: "S7 target",
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
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;
|
||
|
|
|
||
|
|
const targetPackage = {
|
||
|
|
schema: "mnote.agent_target_package.v1",
|
||
|
|
source: "s7_smoke",
|
||
|
|
frozenAt: Date.now(),
|
||
|
|
primaryTargetId: documentId,
|
||
|
|
workspaceId,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
rootUri,
|
||
|
|
documentId,
|
||
|
|
resourceKind: "page",
|
||
|
|
workspacePath: {
|
||
|
|
schema: "mnote.workspace_path.v1",
|
||
|
|
workspaceId,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
rootUri,
|
||
|
|
relativePath: pagePath,
|
||
|
|
documentId,
|
||
|
|
resourceKind: "page",
|
||
|
|
},
|
||
|
|
currentFile: {
|
||
|
|
rootUri,
|
||
|
|
relativePath: pagePath,
|
||
|
|
documentId,
|
||
|
|
resourceKind: "page",
|
||
|
|
},
|
||
|
|
allowedFiles: [pagePath],
|
||
|
|
targets: [
|
||
|
|
{
|
||
|
|
targetId: documentId,
|
||
|
|
documentId,
|
||
|
|
workspaceId,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
rootUri,
|
||
|
|
relativePath: pagePath,
|
||
|
|
resourceKind: "page",
|
||
|
|
dirtyState: "clean",
|
||
|
|
policy: {
|
||
|
|
permission: "read_write",
|
||
|
|
writeRequiresCleanBuffer: true,
|
||
|
|
conflictPolicy: "fail_on_dirty_or_stale",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
policy: {
|
||
|
|
writeRequiresExplicitTarget: true,
|
||
|
|
allowedFilesSource: "selected_page_ai_target",
|
||
|
|
conflictPolicy: "fail_on_dirty_or_stale",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Server must accept targetPackage on send (deserialize, not 422).
|
||
|
|
const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify({
|
||
|
|
sessionId: sid,
|
||
|
|
message: "S7 with target package",
|
||
|
|
rootUri,
|
||
|
|
workspaceId,
|
||
|
|
pagePath,
|
||
|
|
pageTitle: "S7 target",
|
||
|
|
targetPackage,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
assert(
|
||
|
|
send.status === 200 && send.body.ok !== false,
|
||
|
|
`send with targetPackage: ${send.status} ${JSON.stringify(send.body)}`,
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
send.body.accepted === true || send.body.sessionId || send.body.ok === true,
|
||
|
|
`send should accept prompt: ${JSON.stringify(send.body)}`,
|
||
|
|
);
|
||
|
|
console.log(" send accepts targetPackage");
|
||
|
|
|
||
|
|
// buffer-state query shape (may 404 if no buffer opened — still valid API).
|
||
|
|
const bufferQs = new URLSearchParams({
|
||
|
|
documentId,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
rootUri,
|
||
|
|
workspaceId,
|
||
|
|
relativePath: pagePath,
|
||
|
|
});
|
||
|
|
const buffer = await fetchJson(`${BASE}/api/documents/buffer-state?${bufferQs.toString()}`);
|
||
|
|
// Not found is OK for never-opened buffer; found must include dirtyState when present.
|
||
|
|
if (buffer.status === 200 && buffer.body.ok === true) {
|
||
|
|
const result = buffer.body.result || buffer.body;
|
||
|
|
assert(
|
||
|
|
result.dirtyState !== undefined || result.dirty_state !== undefined || result.status,
|
||
|
|
`buffer-state payload missing dirty fields: ${JSON.stringify(buffer.body)}`,
|
||
|
|
);
|
||
|
|
console.log(` buffer-state present dirtyState=${result.dirtyState || result.dirty_state}`);
|
||
|
|
} else {
|
||
|
|
console.log(
|
||
|
|
` buffer-state ${buffer.status} (no open buffer — client gate uses snapshot dirty; OK)`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Client error code contract for UI.
|
||
|
|
const piLab = fs.readFileSync(
|
||
|
|
path.join(__dirname, "..", "rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js"),
|
||
|
|
"utf8",
|
||
|
|
);
|
||
|
|
assert(
|
||
|
|
piLab.includes("page_ai_target_buffer_not_writable"),
|
||
|
|
"UI must surface page_ai_target_buffer_not_writable",
|
||
|
|
);
|
||
|
|
console.log(" dirty conflict code page_ai_target_buffer_not_writable present");
|
||
|
|
|
||
|
|
console.log("\n✅ S7 agent target package + dirty guard smoke passed\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((err) => {
|
||
|
|
console.error("\n❌ S7 smoke failed:", err.message || err);
|
||
|
|
process.exit(1);
|
||
|
|
});
|