Add plannotator extension v0.19.10
This commit is contained in:
480
extensions/plannotator/server/serverPlan.ts
Normal file
480
extensions/plannotator/server/serverPlan.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { contentHash, deleteDraft } from "../generated/draft.js";
|
||||
import {
|
||||
type ArchivedPlan,
|
||||
generateSlug,
|
||||
getPlanVersion,
|
||||
getPlanVersionPath,
|
||||
getVersionCount,
|
||||
listArchivedPlans,
|
||||
listVersions,
|
||||
readArchivedPlan,
|
||||
saveAnnotations,
|
||||
saveFinalSnapshot,
|
||||
saveToHistory,
|
||||
} from "../generated/storage.js";
|
||||
import { createEditorAnnotationHandler } from "./annotations.js";
|
||||
import { createExternalAnnotationHandler } from "./external-annotations.js";
|
||||
import {
|
||||
handleDraftRequest,
|
||||
handleFavicon,
|
||||
handleImageRequest,
|
||||
handleUploadRequest,
|
||||
} from "./handlers.js";
|
||||
import { html, json, parseBody, requestUrl } from "./helpers.js";
|
||||
import { openEditorDiff } from "./ide.js";
|
||||
import {
|
||||
type BearConfig,
|
||||
type IntegrationResult,
|
||||
type ObsidianConfig,
|
||||
type OctarineConfig,
|
||||
saveToBear,
|
||||
saveToObsidian,
|
||||
saveToOctarine,
|
||||
} from "./integrations.js";
|
||||
import { listenOnPort } from "./network.js";
|
||||
|
||||
import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js";
|
||||
import { detectProjectName, getRepoInfo } from "./project.js";
|
||||
import {
|
||||
handleDocRequest,
|
||||
handleDocExistsRequest,
|
||||
handleFileBrowserRequest,
|
||||
handleObsidianDocRequest,
|
||||
handleObsidianFilesRequest,
|
||||
handleObsidianVaultsRequest,
|
||||
} from "./reference.js";
|
||||
import { warmFileListCache } from "../generated/resolve-file.js";
|
||||
|
||||
export interface PlanReviewDecision {
|
||||
approved: boolean;
|
||||
feedback?: string;
|
||||
savedPath?: string;
|
||||
agentSwitch?: string;
|
||||
permissionMode?: string;
|
||||
}
|
||||
|
||||
export interface PlanServerResult {
|
||||
reviewId: string;
|
||||
port: number;
|
||||
portSource: "env" | "remote-default" | "random";
|
||||
url: string;
|
||||
waitForDecision: () => Promise<PlanReviewDecision>;
|
||||
onDecision: (listener: (result: PlanReviewDecision) => void | Promise<void>) => () => void;
|
||||
waitForDone?: () => Promise<void>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export async function startPlanReviewServer(options: {
|
||||
plan: string;
|
||||
htmlContent: string;
|
||||
origin?: string;
|
||||
permissionMode?: string;
|
||||
sharingEnabled?: boolean;
|
||||
shareBaseUrl?: string;
|
||||
pasteApiUrl?: string;
|
||||
mode?: "archive";
|
||||
customPlanPath?: string | null;
|
||||
}): Promise<PlanServerResult> {
|
||||
// Side-channel pre-warm so /api/doc/exists POSTs land on warm cache.
|
||||
void warmFileListCache(process.cwd(), "code");
|
||||
const gitUser = detectGitUser();
|
||||
const sharingEnabled =
|
||||
options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled";
|
||||
const shareBaseUrl =
|
||||
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
|
||||
const pasteApiUrl =
|
||||
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
|
||||
|
||||
// --- Archive mode setup ---
|
||||
let archivePlans: ArchivedPlan[] = [];
|
||||
let initialArchivePlan = "";
|
||||
let resolveDone: (() => void) | undefined;
|
||||
let donePromise: Promise<void> | undefined;
|
||||
|
||||
if (options.mode === "archive") {
|
||||
archivePlans = listArchivedPlans(options.customPlanPath ?? undefined);
|
||||
initialArchivePlan =
|
||||
archivePlans.length > 0
|
||||
? (readArchivedPlan(
|
||||
archivePlans[0].filename,
|
||||
options.customPlanPath ?? undefined,
|
||||
) ?? "")
|
||||
: "";
|
||||
donePromise = new Promise<void>((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Plan review mode setup (skip in archive mode) ---
|
||||
const repoInfo = options.mode !== "archive" ? getRepoInfo() : null;
|
||||
const slug = options.mode !== "archive" ? generateSlug(options.plan) : "";
|
||||
const project = options.mode !== "archive" ? detectProjectName() : "";
|
||||
const historyResult =
|
||||
options.mode !== "archive"
|
||||
? saveToHistory(project, slug, options.plan)
|
||||
: { version: 0, path: "", isNew: false };
|
||||
const previousPlan =
|
||||
options.mode !== "archive" && historyResult.version > 1
|
||||
? getPlanVersion(project, slug, historyResult.version - 1)
|
||||
: null;
|
||||
const versionInfo =
|
||||
options.mode !== "archive"
|
||||
? {
|
||||
version: historyResult.version,
|
||||
totalVersions: getVersionCount(project, slug),
|
||||
project,
|
||||
}
|
||||
: null;
|
||||
|
||||
const reviewId = randomUUID();
|
||||
let resolveDecision!: (result: PlanReviewDecision) => void;
|
||||
const decisionListeners = new Set<(result: PlanReviewDecision) => void | Promise<void>>();
|
||||
let decisionSettled = false;
|
||||
const decisionPromise = new Promise<PlanReviewDecision>((r) => {
|
||||
resolveDecision = r;
|
||||
});
|
||||
const publishDecision = (result: PlanReviewDecision): boolean => {
|
||||
if (decisionSettled) return false;
|
||||
decisionSettled = true;
|
||||
resolveDecision(result);
|
||||
for (const listener of decisionListeners) {
|
||||
Promise.resolve(listener(result)).catch((error) => {
|
||||
console.error("[Plan Review] Decision listener failed:", error);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Draft key for annotation persistence
|
||||
const draftKey = options.mode !== "archive" ? contentHash(options.plan) : "";
|
||||
|
||||
// Editor annotations (in-memory, VS Code integration — skip in archive mode)
|
||||
const editorAnnotations = options.mode !== "archive" ? createEditorAnnotationHandler() : null;
|
||||
const externalAnnotations = options.mode !== "archive" ? createExternalAnnotationHandler("plan") : null;
|
||||
|
||||
// Lazy cache for in-session archive tab
|
||||
let cachedArchivePlans: ArchivedPlan[] | null = null;
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = requestUrl(req);
|
||||
|
||||
if (url.pathname === "/api/done" && req.method === "POST") {
|
||||
resolveDone?.();
|
||||
json(res, { ok: true });
|
||||
} else if (url.pathname === "/api/archive/plans" && req.method === "GET") {
|
||||
const customPath = url.searchParams.get("customPath") || undefined;
|
||||
if (!cachedArchivePlans)
|
||||
cachedArchivePlans = listArchivedPlans(customPath);
|
||||
json(res, { plans: cachedArchivePlans });
|
||||
} else if (url.pathname === "/api/archive/plan" && req.method === "GET") {
|
||||
const filename = url.searchParams.get("filename");
|
||||
const customPath = url.searchParams.get("customPath") || undefined;
|
||||
if (!filename) {
|
||||
json(res, { error: "Missing filename" }, 400);
|
||||
return;
|
||||
}
|
||||
const markdown = readArchivedPlan(filename, customPath);
|
||||
if (!markdown) {
|
||||
json(res, { error: "Not found" }, 404);
|
||||
return;
|
||||
}
|
||||
json(res, { markdown, filepath: filename });
|
||||
} else if (url.pathname === "/api/plan/version") {
|
||||
const vParam = url.searchParams.get("v");
|
||||
if (!vParam) {
|
||||
json(res, { error: "Missing v parameter" }, 400);
|
||||
return;
|
||||
}
|
||||
const v = parseInt(vParam, 10);
|
||||
if (Number.isNaN(v) || v < 1) {
|
||||
json(res, { error: "Invalid version number" }, 400);
|
||||
return;
|
||||
}
|
||||
const content = getPlanVersion(project, slug, v);
|
||||
if (content === null) {
|
||||
json(res, { error: "Version not found" }, 404);
|
||||
return;
|
||||
}
|
||||
json(res, { plan: content, version: v });
|
||||
} else if (url.pathname === "/api/plan/versions") {
|
||||
json(res, { project, slug, versions: listVersions(project, slug) });
|
||||
} else if (url.pathname === "/api/plan") {
|
||||
if (options.mode === "archive") {
|
||||
json(res, {
|
||||
plan: initialArchivePlan,
|
||||
origin: options.origin ?? "pi",
|
||||
mode: "archive",
|
||||
archivePlans,
|
||||
sharingEnabled,
|
||||
shareBaseUrl,
|
||||
serverConfig: getServerConfig(gitUser),
|
||||
});
|
||||
} else {
|
||||
json(res, {
|
||||
plan: options.plan,
|
||||
origin: options.origin ?? "pi",
|
||||
permissionMode: options.permissionMode,
|
||||
previousPlan,
|
||||
versionInfo,
|
||||
sharingEnabled,
|
||||
shareBaseUrl,
|
||||
pasteApiUrl,
|
||||
repoInfo,
|
||||
projectRoot: process.cwd(),
|
||||
serverConfig: getServerConfig(gitUser),
|
||||
});
|
||||
}
|
||||
} else if (url.pathname === "/api/config" && req.method === "POST") {
|
||||
try {
|
||||
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
|
||||
const toSave: Record<string, unknown> = {};
|
||||
if (body.displayName !== undefined) toSave.displayName = body.displayName;
|
||||
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
|
||||
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
|
||||
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
|
||||
json(res, { ok: true });
|
||||
} catch {
|
||||
json(res, { error: "Invalid request" }, 400);
|
||||
}
|
||||
} else if (url.pathname === "/api/image") {
|
||||
handleImageRequest(res, url);
|
||||
} else if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||
await handleUploadRequest(req, res);
|
||||
} else if (url.pathname === "/api/draft") {
|
||||
await handleDraftRequest(req, res, draftKey);
|
||||
} else if (editorAnnotations && (await editorAnnotations.handle(req, res, url))) {
|
||||
return;
|
||||
} else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) {
|
||||
return;
|
||||
} else if (url.pathname === "/api/doc" && req.method === "GET") {
|
||||
await handleDocRequest(res, url);
|
||||
} else if (url.pathname === "/api/doc/exists" && req.method === "POST") {
|
||||
await handleDocExistsRequest(res, req);
|
||||
} else if (url.pathname === "/api/obsidian/vaults") {
|
||||
handleObsidianVaultsRequest(res);
|
||||
} else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
|
||||
handleObsidianFilesRequest(res, url);
|
||||
} else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
|
||||
handleObsidianDocRequest(res, url);
|
||||
} else if (url.pathname === "/api/reference/files" && req.method === "GET") {
|
||||
handleFileBrowserRequest(res, url);
|
||||
} else if (
|
||||
url.pathname === "/api/plan/vscode-diff" &&
|
||||
req.method === "POST"
|
||||
) {
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
const baseVersion = body.baseVersion as number;
|
||||
if (!baseVersion) {
|
||||
json(res, { error: "Missing baseVersion" }, 400);
|
||||
return;
|
||||
}
|
||||
const basePath = getPlanVersionPath(project, slug, baseVersion);
|
||||
if (!basePath) {
|
||||
json(res, { error: `Version ${baseVersion} not found` }, 404);
|
||||
return;
|
||||
}
|
||||
const result = await openEditorDiff(basePath, historyResult.path);
|
||||
if ("error" in result) {
|
||||
json(res, { error: result.error }, 500);
|
||||
return;
|
||||
}
|
||||
json(res, { ok: true });
|
||||
} catch (err) {
|
||||
json(
|
||||
res,
|
||||
{
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to open VS Code diff",
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
} else if (url.pathname === "/api/agents" && req.method === "GET") {
|
||||
json(res, { agents: [] });
|
||||
} else if (url.pathname === "/favicon.svg") {
|
||||
handleFavicon(res);
|
||||
} else if (url.pathname === "/api/save-notes" && req.method === "POST") {
|
||||
const results: {
|
||||
obsidian?: IntegrationResult;
|
||||
bear?: IntegrationResult;
|
||||
octarine?: IntegrationResult;
|
||||
} = {};
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
const promises: Promise<void>[] = [];
|
||||
const obsConfig = body.obsidian as ObsidianConfig | undefined;
|
||||
const bearConfig = body.bear as BearConfig | undefined;
|
||||
const octConfig = body.octarine as OctarineConfig | undefined;
|
||||
if (obsConfig?.vaultPath && obsConfig?.plan) {
|
||||
promises.push(
|
||||
saveToObsidian(obsConfig).then((r) => {
|
||||
results.obsidian = r;
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (bearConfig?.plan) {
|
||||
promises.push(
|
||||
saveToBear(bearConfig).then((r) => {
|
||||
results.bear = r;
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (octConfig?.plan && octConfig?.workspace) {
|
||||
promises.push(
|
||||
saveToOctarine(octConfig).then((r) => {
|
||||
results.octarine = r;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
if (!result?.success && result)
|
||||
console.error(`[${name}] Save failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Save Notes] Error:`, err);
|
||||
json(res, { error: "Save failed" }, 500);
|
||||
return;
|
||||
}
|
||||
json(res, { ok: true, results });
|
||||
} else if (url.pathname === "/api/approve" && req.method === "POST") {
|
||||
if (decisionSettled) {
|
||||
json(res, { ok: true, duplicate: true });
|
||||
return;
|
||||
}
|
||||
let feedback: string | undefined;
|
||||
let agentSwitch: string | undefined;
|
||||
let requestedPermissionMode: string | undefined;
|
||||
let planSaveEnabled = true;
|
||||
let planSaveCustomPath: string | undefined;
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
if (body.feedback) feedback = body.feedback as string;
|
||||
if (body.agentSwitch) agentSwitch = body.agentSwitch as string;
|
||||
if (body.permissionMode)
|
||||
requestedPermissionMode = body.permissionMode as string;
|
||||
if (body.planSave !== undefined) {
|
||||
const ps = body.planSave as { enabled: boolean; customPath?: string };
|
||||
planSaveEnabled = ps.enabled;
|
||||
planSaveCustomPath = ps.customPath;
|
||||
}
|
||||
// Run note integrations in parallel
|
||||
const integrationResults: Record<string, IntegrationResult> = {};
|
||||
const integrationPromises: Promise<void>[] = [];
|
||||
const obsConfig = body.obsidian as ObsidianConfig | undefined;
|
||||
const bearConfig = body.bear as BearConfig | undefined;
|
||||
const octConfig = body.octarine as OctarineConfig | undefined;
|
||||
if (obsConfig?.vaultPath && obsConfig?.plan) {
|
||||
integrationPromises.push(
|
||||
saveToObsidian(obsConfig).then((r) => {
|
||||
integrationResults.obsidian = r;
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (bearConfig?.plan) {
|
||||
integrationPromises.push(
|
||||
saveToBear(bearConfig).then((r) => {
|
||||
integrationResults.bear = r;
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (octConfig?.plan && octConfig?.workspace) {
|
||||
integrationPromises.push(
|
||||
saveToOctarine(octConfig).then((r) => {
|
||||
integrationResults.octarine = r;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.allSettled(integrationPromises);
|
||||
for (const [name, result] of Object.entries(integrationResults)) {
|
||||
if (!result?.success && result)
|
||||
console.error(`[${name}] Save failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Integration] Error:`, err);
|
||||
}
|
||||
// Save annotations and final snapshot
|
||||
let savedPath: string | undefined;
|
||||
if (planSaveEnabled) {
|
||||
const annotations = feedback || "";
|
||||
if (annotations) saveAnnotations(slug, annotations, planSaveCustomPath);
|
||||
savedPath = saveFinalSnapshot(
|
||||
slug,
|
||||
"approved",
|
||||
options.plan,
|
||||
annotations,
|
||||
planSaveCustomPath,
|
||||
);
|
||||
}
|
||||
deleteDraft(draftKey);
|
||||
const effectivePermissionMode = requestedPermissionMode || options.permissionMode;
|
||||
publishDecision({
|
||||
approved: true,
|
||||
feedback,
|
||||
savedPath,
|
||||
agentSwitch,
|
||||
permissionMode: effectivePermissionMode,
|
||||
});
|
||||
json(res, { ok: true, savedPath });
|
||||
} else if (url.pathname === "/api/deny" && req.method === "POST") {
|
||||
if (decisionSettled) {
|
||||
json(res, { ok: true, duplicate: true });
|
||||
return;
|
||||
}
|
||||
let feedback = "Plan rejected by user";
|
||||
let planSaveEnabled = true;
|
||||
let planSaveCustomPath: string | undefined;
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
feedback = (body.feedback as string) || feedback;
|
||||
if (body.planSave !== undefined) {
|
||||
const ps = body.planSave as { enabled: boolean; customPath?: string };
|
||||
planSaveEnabled = ps.enabled;
|
||||
planSaveCustomPath = ps.customPath;
|
||||
}
|
||||
} catch {
|
||||
/* use default feedback */
|
||||
}
|
||||
let savedPath: string | undefined;
|
||||
if (planSaveEnabled) {
|
||||
saveAnnotations(slug, feedback, planSaveCustomPath);
|
||||
savedPath = saveFinalSnapshot(
|
||||
slug,
|
||||
"denied",
|
||||
options.plan,
|
||||
feedback,
|
||||
planSaveCustomPath,
|
||||
);
|
||||
}
|
||||
deleteDraft(draftKey);
|
||||
publishDecision({ approved: false, feedback, savedPath });
|
||||
json(res, { ok: true, savedPath });
|
||||
} else {
|
||||
html(res, options.htmlContent);
|
||||
}
|
||||
});
|
||||
|
||||
const { port, portSource } = await listenOnPort(server);
|
||||
|
||||
return {
|
||||
reviewId,
|
||||
port,
|
||||
portSource,
|
||||
url: `http://localhost:${port}`,
|
||||
waitForDecision: () => decisionPromise,
|
||||
onDecision: (listener) => {
|
||||
decisionListeners.add(listener);
|
||||
return () => {
|
||||
decisionListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
...(donePromise && { waitForDone: () => donePromise }),
|
||||
stop: () => server.close(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user