@unbrained/pm-web 2026.7.25 → 2026.7.27
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/CHANGELOG.md +20 -1
- package/README.md +15 -6
- package/dist/index.js +10 -7
- package/dist/index.js.map +1 -1
- package/dist/routes/pm.js +152 -47
- package/dist/routes/pm.js.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/services/mutation-event-watcher.d.ts +48 -0
- package/dist/services/mutation-event-watcher.js +171 -0
- package/dist/services/mutation-event-watcher.js.map +1 -0
- package/dist/services/pm-runner.d.ts +35 -2
- package/dist/services/pm-runner.js +302 -4
- package/dist/services/pm-runner.js.map +1 -1
- package/dist/services/project-watcher.js +9 -16
- package/dist/services/project-watcher.js.map +1 -1
- package/dist/services/sse.d.ts +2 -0
- package/dist/services/sse.js +72 -0
- package/dist/services/sse.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type MutationEvent } from "@unbrained/pm-cli/sdk";
|
|
2
|
+
import { type SSEEvent } from "./sse.js";
|
|
3
|
+
/** Options for the async generator subscription seam. Mirrors the SDK's `SubscribeMutationEventsOptions`. */
|
|
4
|
+
export interface SubscribeOptions {
|
|
5
|
+
/** Tracker root path (`.agents/pm` under the project directory). */
|
|
6
|
+
pmRoot: string;
|
|
7
|
+
/** Opaque cursor or inclusive ISO timestamp lower bound. */
|
|
8
|
+
since: string;
|
|
9
|
+
/** Delay between empty catch-up reads. */
|
|
10
|
+
intervalMs: number;
|
|
11
|
+
/** Cancellation signal for a long-lived subscription. */
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
/** Result of the subscribe seam — an async generator yielding mutation events. */
|
|
15
|
+
export type SubscribeFn = (options: SubscribeOptions) => AsyncGenerator<MutationEvent, void, void>;
|
|
16
|
+
/** Dependencies for `createMutationEventReconciler`, all injectable for testing. */
|
|
17
|
+
export interface MutationEventWatcherDeps {
|
|
18
|
+
/** Delay between empty catch-up reads passed to the SDK subscription (default 250, floor 10). */
|
|
19
|
+
intervalMs?: number;
|
|
20
|
+
/** Reconcile interval for the periodic sweep (default 2000, floor 500). */
|
|
21
|
+
reconcileMs?: number;
|
|
22
|
+
/** Returns the currently active (SSE-connected) project ids. */
|
|
23
|
+
getActiveProjectIds?: () => string[];
|
|
24
|
+
/** Resolves a project id to its on-disk directory, or null when the project row is absent. */
|
|
25
|
+
resolveProjectDir?: (projectId: string) => Promise<string | null>;
|
|
26
|
+
/** SDK subscription factory; defaults to the real `subscribeMutationEvents`. */
|
|
27
|
+
subscribe?: SubscribeFn;
|
|
28
|
+
/** Checks whether an item mutation was already announced by this instance; returns true and consumes it. */
|
|
29
|
+
consumeSignaledItemMutation?: (projectId: string, itemId: string) => boolean;
|
|
30
|
+
/** Emits an SSE event to the local clients of one project. */
|
|
31
|
+
emit?: (projectId: string, event: SSEEvent) => void;
|
|
32
|
+
/** Error sink for non-abort errors from a subscription loop. */
|
|
33
|
+
onError?: (err: unknown) => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Pure, testable reconciler. Holds per-project subscription state across
|
|
37
|
+
* reconcile cycles. Drives the watcher without timers — tests call `reconcile()`
|
|
38
|
+
* directly. Follows the same dependency-injection style as `createProjectWatchCycle`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createMutationEventReconciler(deps?: MutationEventWatcherDeps): {
|
|
41
|
+
reconcile: () => Promise<void>;
|
|
42
|
+
stopAll: () => Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Start the mutation-event watcher. Returns a stop function, mirroring
|
|
46
|
+
* `startProjectWatcher`. Disabled entirely when `PM_REALTIME_MUTATION_EVENTS=false`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function startMutationEventWatcher(deps?: MutationEventWatcherDeps): () => void;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Per-project mutation-event subscription manager.
|
|
2
|
+
//
|
|
3
|
+
// The primary out-of-band change detector for pm-web. Subscribes to the pm SDK's
|
|
4
|
+
// `subscribeMutationEvents` async generator for each active project, which reads
|
|
5
|
+
// from a persistent derived index of committed mutation facts (NOT a filesystem
|
|
6
|
+
// scan). Another process's mutation appears on this follower within `intervalMs`.
|
|
7
|
+
//
|
|
8
|
+
// Raw file edits that bypass pm (git merge, rsync restore, manual writes) produce
|
|
9
|
+
// NO mutation event — the stream is a committed-mutation fact stream derived from
|
|
10
|
+
// history. The filesystem sweep in project-watcher.ts is therefore KEPT as a safety
|
|
11
|
+
// net for those bypass-pm writes; this module is the primary path for pm-authored
|
|
12
|
+
// mutations.
|
|
13
|
+
//
|
|
14
|
+
// Events are emitted LOCALLY only (deliverProjectEvent, NOT broadcastProjectEvent).
|
|
15
|
+
// Every pm-web instance reads the same shared volume and will independently observe
|
|
16
|
+
// the same mutation events; publishing to the Postgres bus would duplicate them
|
|
17
|
+
// across instances. Each instance dedupes against its own API broadcasts via the
|
|
18
|
+
// per-item signal in sse.ts.
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { subscribeMutationEvents } from "@unbrained/pm-cli/sdk";
|
|
21
|
+
import { resolveProjectDir } from "./pm-runner.js";
|
|
22
|
+
import { consumeSignaledItemMutation, deliverProjectEvent, getActiveProjectIds, } from "./sse.js";
|
|
23
|
+
const DEFAULT_INTERVAL_MS = 250;
|
|
24
|
+
const MIN_INTERVAL_MS = 10;
|
|
25
|
+
const DEFAULT_RECONCILE_MS = 2_000;
|
|
26
|
+
const MIN_RECONCILE_MS = 500;
|
|
27
|
+
function positiveIntEnv(name, fallback) {
|
|
28
|
+
const raw = process.env[name];
|
|
29
|
+
if (!raw)
|
|
30
|
+
return fallback;
|
|
31
|
+
const n = Number.parseInt(raw, 10);
|
|
32
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
33
|
+
}
|
|
34
|
+
// Determine whether an error from the subscription loop is an AbortError caused
|
|
35
|
+
// by a deliberate stop (project went inactive or shutdown). Those must NOT be
|
|
36
|
+
// reported as errors — only genuine failures are routed to onError.
|
|
37
|
+
function isAbortError(err) {
|
|
38
|
+
if (err instanceof Error) {
|
|
39
|
+
return err.name === "AbortError" || /^aborted/i.test(err.message);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Pure, testable reconciler. Holds per-project subscription state across
|
|
45
|
+
* reconcile cycles. Drives the watcher without timers — tests call `reconcile()`
|
|
46
|
+
* directly. Follows the same dependency-injection style as `createProjectWatchCycle`.
|
|
47
|
+
*/
|
|
48
|
+
export function createMutationEventReconciler(deps = {}) {
|
|
49
|
+
const intervalMs = Math.max(MIN_INTERVAL_MS, deps.intervalMs ?? positiveIntEnv("PM_MUTATION_EVENT_INTERVAL_MS", DEFAULT_INTERVAL_MS));
|
|
50
|
+
const getIds = deps.getActiveProjectIds ?? getActiveProjectIds;
|
|
51
|
+
const resolveDir = deps.resolveProjectDir ?? resolveProjectDir;
|
|
52
|
+
const subscribe = deps.subscribe ?? subscribeMutationEvents;
|
|
53
|
+
const consumeSignal = deps.consumeSignaledItemMutation ?? consumeSignaledItemMutation;
|
|
54
|
+
const emit = deps.emit ?? deliverProjectEvent;
|
|
55
|
+
const onError = deps.onError ?? (() => undefined);
|
|
56
|
+
const subs = new Map();
|
|
57
|
+
const dirCache = new Map();
|
|
58
|
+
let inFlight = false;
|
|
59
|
+
// Start (or restart after error) a detached async loop that consumes the SDK
|
|
60
|
+
// generator for one project. Runs independently of reconcile; errors abort
|
|
61
|
+
// the subscription and the next reconcile re-establishes it from the stored cursor.
|
|
62
|
+
function startLoop(projectId, dir, since) {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const sub = { controller, cursor: since, done: false };
|
|
65
|
+
subs.set(projectId, sub);
|
|
66
|
+
const pmRoot = path.join(dir, ".agents", "pm");
|
|
67
|
+
// Detached async loop — intentionally not awaited by reconcile.
|
|
68
|
+
void (async () => {
|
|
69
|
+
try {
|
|
70
|
+
const gen = subscribe({ pmRoot, since, intervalMs, signal: controller.signal });
|
|
71
|
+
for await (const event of gen) {
|
|
72
|
+
sub.cursor = event.cursor;
|
|
73
|
+
// Skip events that this instance's own API already announced (per-item dedupe).
|
|
74
|
+
if (consumeSignal(projectId, event.item_id))
|
|
75
|
+
continue;
|
|
76
|
+
emit(projectId, {
|
|
77
|
+
type: "workspace-changed",
|
|
78
|
+
data: {
|
|
79
|
+
source: "mutation-events",
|
|
80
|
+
itemId: event.item_id,
|
|
81
|
+
operation: event.type,
|
|
82
|
+
author: event.author,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (!isAbortError(err))
|
|
89
|
+
onError(err);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
sub.done = true;
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
}
|
|
96
|
+
const reconcile = async () => {
|
|
97
|
+
if (inFlight)
|
|
98
|
+
return; // never overlap reconciles
|
|
99
|
+
inFlight = true;
|
|
100
|
+
try {
|
|
101
|
+
const ids = getIds();
|
|
102
|
+
const active = new Set(ids);
|
|
103
|
+
// Abort and drop subscriptions for projects no longer active.
|
|
104
|
+
for (const [id, sub] of subs) {
|
|
105
|
+
if (!active.has(id)) {
|
|
106
|
+
sub.controller.abort();
|
|
107
|
+
subs.delete(id);
|
|
108
|
+
dirCache.delete(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Clean up dir cache for inactive projects.
|
|
112
|
+
for (const id of [...dirCache.keys()])
|
|
113
|
+
if (!active.has(id))
|
|
114
|
+
dirCache.delete(id);
|
|
115
|
+
for (const projectId of ids) {
|
|
116
|
+
try {
|
|
117
|
+
let dir = dirCache.get(projectId);
|
|
118
|
+
if (dir === undefined) {
|
|
119
|
+
dir = await resolveDir(projectId);
|
|
120
|
+
dirCache.set(projectId, dir);
|
|
121
|
+
}
|
|
122
|
+
if (!dir)
|
|
123
|
+
continue;
|
|
124
|
+
const existing = subs.get(projectId);
|
|
125
|
+
if (existing) {
|
|
126
|
+
// If the loop exited (e.g. after an error), restart from the stored cursor.
|
|
127
|
+
if (existing.done) {
|
|
128
|
+
startLoop(projectId, dir, existing.cursor);
|
|
129
|
+
}
|
|
130
|
+
// Otherwise the loop is still running — leave it alone.
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// Newly-active project: start at the tail (no history replay).
|
|
134
|
+
startLoop(projectId, dir, new Date().toISOString());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
onError(err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
inFlight = false;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const stopAll = async () => {
|
|
147
|
+
for (const sub of subs.values())
|
|
148
|
+
sub.controller.abort();
|
|
149
|
+
subs.clear();
|
|
150
|
+
dirCache.clear();
|
|
151
|
+
};
|
|
152
|
+
return { reconcile, stopAll };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Start the mutation-event watcher. Returns a stop function, mirroring
|
|
156
|
+
* `startProjectWatcher`. Disabled entirely when `PM_REALTIME_MUTATION_EVENTS=false`.
|
|
157
|
+
*/
|
|
158
|
+
export function startMutationEventWatcher(deps = {}) {
|
|
159
|
+
if (process.env.PM_REALTIME_MUTATION_EVENTS === "false")
|
|
160
|
+
return () => undefined;
|
|
161
|
+
const reconcileMs = Math.max(MIN_RECONCILE_MS, deps.reconcileMs ?? positiveIntEnv("PM_MUTATION_EVENT_RECONCILE_MS", DEFAULT_RECONCILE_MS));
|
|
162
|
+
const onError = deps.onError ?? ((err) => console.error("Mutation-event watcher reconcile failed", err instanceof Error ? err.message : err));
|
|
163
|
+
const { reconcile, stopAll } = createMutationEventReconciler({ ...deps, onError });
|
|
164
|
+
const timer = setInterval(() => { void reconcile().catch(onError); }, reconcileMs);
|
|
165
|
+
timer.unref();
|
|
166
|
+
return () => {
|
|
167
|
+
clearInterval(timer);
|
|
168
|
+
void stopAll();
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=mutation-event-watcher.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mutation-event-watcher.js","sourceRoot":"","sources":["../../src/services/mutation-event-watcher.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { PmCliError, EXIT_CODE, type GetItemAtResult } from "@unbrained/pm-cli/sdk";
|
|
2
|
-
export { PmCliError, EXIT_CODE, type GetItemAtResult };
|
|
1
|
+
import { PmClient, PmCliError, isPmCliExpectedError, EXIT_CODE, type GetItemAtResult } from "@unbrained/pm-cli/sdk";
|
|
2
|
+
export { PmCliError, isPmCliExpectedError, EXIT_CODE, type GetItemAtResult };
|
|
3
3
|
export declare class Semaphore {
|
|
4
4
|
private readonly limit;
|
|
5
5
|
private active;
|
|
@@ -9,6 +9,22 @@ export declare class Semaphore {
|
|
|
9
9
|
}
|
|
10
10
|
export declare function projectsRoot(): string;
|
|
11
11
|
export declare function getProjectDir(userId: string, slug: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a project id to its on-disk directory, or `null` when the project row
|
|
14
|
+
* is genuinely absent.
|
|
15
|
+
*
|
|
16
|
+
* Shared by both out-of-band change detectors (the mutation-event subscription
|
|
17
|
+
* and the filesystem safety-net sweep), which each cache the result per active
|
|
18
|
+
* SSE session. It lives here because this module already owns the
|
|
19
|
+
* project-id → path mapping via {@link getProjectDir}.
|
|
20
|
+
*
|
|
21
|
+
* Database errors are deliberately **not** swallowed: a transient `pool.query`
|
|
22
|
+
* failure must reach the caller's per-project error handling so the lookup is
|
|
23
|
+
* retried. Returning `null` on failure would let a caller cache "no such
|
|
24
|
+
* project" for the whole session and permanently stop watching it. `null`
|
|
25
|
+
* therefore means "the row is absent", which is safe to cache.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveProjectDir(projectId: string): Promise<string | null>;
|
|
12
28
|
export declare function initProject(userId: string, slug: string, prefix: string): Promise<void>;
|
|
13
29
|
export declare function projectExists(userId: string, slug: string): boolean;
|
|
14
30
|
export interface PmRunOptions {
|
|
@@ -24,6 +40,8 @@ export interface PmRunResult {
|
|
|
24
40
|
stderr: string;
|
|
25
41
|
ok: boolean;
|
|
26
42
|
parsed?: unknown;
|
|
43
|
+
/** pm CLI exit code from either the SDK dispatcher or spawned CLI fallback. */
|
|
44
|
+
exitCode?: number;
|
|
27
45
|
}
|
|
28
46
|
export interface EnsureGraphExtensionResult {
|
|
29
47
|
ok: boolean;
|
|
@@ -32,6 +50,21 @@ export interface EnsureGraphExtensionResult {
|
|
|
32
50
|
error?: string;
|
|
33
51
|
}
|
|
34
52
|
export declare function ensureGraphExtension(userId: string, slug: string): Promise<EnsureGraphExtensionResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Return a cached {@link PmClient} for a workspace pm-root, creating one on
|
|
55
|
+
* first use. The SDK owns extension activation and serialization internally;
|
|
56
|
+
* caching avoids reconstructing the immutable workspace defaults while each
|
|
57
|
+
* call still receives the SDK's current extension snapshot. Author identity is
|
|
58
|
+
* resolved by the SDK's default detection, preserving prior CLI behaviour.
|
|
59
|
+
*/
|
|
60
|
+
export declare function getPmClient(pmRoot: string): PmClient;
|
|
61
|
+
/** Drop a cached client when its workspace is deleted. */
|
|
62
|
+
export declare function evictPmClient(pmRoot: string): void;
|
|
63
|
+
/**
|
|
64
|
+
* Read a workspace's parsed `settings.json` for the search-tuning resolvers.
|
|
65
|
+
* Returns `{}` when absent so resolvers fall back to their built-in defaults.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readPmSettings(userId: string, slug: string): unknown;
|
|
35
68
|
export declare function runPm(opts: PmRunOptions): Promise<PmRunResult>;
|
|
36
69
|
/**
|
|
37
70
|
* Reconstruct a single item at a one-based version or ISO timestamp using the
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { pool } from "../db.js";
|
|
5
|
+
import { getItemAt, PM_TOOL_PARAMETERS_SCHEMA, PmClient, PmCliError, isPmCliExpectedError, EXIT_CODE, } from "@unbrained/pm-cli/sdk";
|
|
5
6
|
// Re-exported so route handlers and tests can reference the verified projection
|
|
6
7
|
// shape and the typed error class without reaching into the SDK package map.
|
|
7
|
-
export { PmCliError, EXIT_CODE };
|
|
8
|
+
export { PmCliError, isPmCliExpectedError, EXIT_CODE };
|
|
8
9
|
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
|
9
10
|
const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
10
11
|
function positiveInteger(value, fallback) {
|
|
@@ -54,6 +55,28 @@ const PM_GRAPH_EXTENSION_PATH = process.env.PM_GRAPH_EXTENSION_PATH ||
|
|
|
54
55
|
export function getProjectDir(userId, slug) {
|
|
55
56
|
return path.join(projectsRoot(), userId, slug);
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a project id to its on-disk directory, or `null` when the project row
|
|
60
|
+
* is genuinely absent.
|
|
61
|
+
*
|
|
62
|
+
* Shared by both out-of-band change detectors (the mutation-event subscription
|
|
63
|
+
* and the filesystem safety-net sweep), which each cache the result per active
|
|
64
|
+
* SSE session. It lives here because this module already owns the
|
|
65
|
+
* project-id → path mapping via {@link getProjectDir}.
|
|
66
|
+
*
|
|
67
|
+
* Database errors are deliberately **not** swallowed: a transient `pool.query`
|
|
68
|
+
* failure must reach the caller's per-project error handling so the lookup is
|
|
69
|
+
* retried. Returning `null` on failure would let a caller cache "no such
|
|
70
|
+
* project" for the whole session and permanently stop watching it. `null`
|
|
71
|
+
* therefore means "the row is absent", which is safe to cache.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveProjectDir(projectId) {
|
|
74
|
+
const res = await pool.query("SELECT user_id, slug FROM pm_projects WHERE id = $1", [projectId]);
|
|
75
|
+
const row = res.rows[0];
|
|
76
|
+
if (!row)
|
|
77
|
+
return null;
|
|
78
|
+
return getProjectDir(row.user_id, row.slug);
|
|
79
|
+
}
|
|
57
80
|
let cachedPmCommand;
|
|
58
81
|
function pmCliCommand() {
|
|
59
82
|
if (process.env.PM_CLI_BIN)
|
|
@@ -135,6 +158,7 @@ async function runProcess(cwd, args, options = {}) {
|
|
|
135
158
|
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
136
159
|
stderr: failure ? `${stderrText}${stderrText ? "\n" : ""}${failure}` : stderrText,
|
|
137
160
|
ok: code === 0 && !failure,
|
|
161
|
+
exitCode: typeof code === "number" ? code : undefined,
|
|
138
162
|
});
|
|
139
163
|
});
|
|
140
164
|
child.stdin.on("error", () => undefined);
|
|
@@ -278,15 +302,288 @@ export async function ensureGraphExtension(userId, slug) {
|
|
|
278
302
|
}
|
|
279
303
|
return { ok: true, installed: true, active: true };
|
|
280
304
|
}
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// In-process SDK dispatch
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
//
|
|
309
|
+
// The per-request `pm` binary spawn was the single biggest scalability defect
|
|
310
|
+
// in this package: every read/write of a workspace forked a node process, ran
|
|
311
|
+
// the CLI bootstrap, and acquired file locks through the OS. The pm CLI SDK
|
|
312
|
+
// (2026.7.26) exposes the same command runners the CLI uses as a typed,
|
|
313
|
+
// in-process `PmClient`. We now dispatch latency-bounded actions through one
|
|
314
|
+
// cached client configuration per workspace pm-root and retain the bounded
|
|
315
|
+
// spawn path for unsupported or potentially long-running actions. The latter
|
|
316
|
+
// prevents the SDK's process-wide activation queue from head-of-line blocking
|
|
317
|
+
// unrelated workspaces (tracked upstream as unbraind/pm-cli#742).
|
|
318
|
+
/**
|
|
319
|
+
* Actions that cannot be served by `PmClient.run` and must keep using the
|
|
320
|
+
* `pm` binary spawn. Each is documented with the concrete reason it is kept.
|
|
321
|
+
*/
|
|
322
|
+
const SPAWN_FALLBACK_ACTIONS = new Set([
|
|
323
|
+
// Semantic search can invoke remote providers. Keep it outside the SDK's
|
|
324
|
+
// process-wide activation queue until unbraind/pm-cli#742 is resolved.
|
|
325
|
+
"search",
|
|
326
|
+
// Bulk updates can touch many items and must not block unrelated workspaces
|
|
327
|
+
// behind the SDK's process-wide activation queue.
|
|
328
|
+
"update-many",
|
|
329
|
+
// Dependency/schema upgrades are long-running maintenance operations.
|
|
330
|
+
"upgrade",
|
|
331
|
+
// Linked acceptance suites execute arbitrary project commands and can run for
|
|
332
|
+
// minutes, so test-all must not occupy the process-wide SDK queue.
|
|
333
|
+
"test-all",
|
|
334
|
+
// Full validation, health diagnostics, and garbage collection scan workspace
|
|
335
|
+
// state and may invoke extension hooks or external checks.
|
|
336
|
+
"validate",
|
|
337
|
+
"health",
|
|
338
|
+
"gc",
|
|
339
|
+
// `pm guide` is a static help renderer with no SDK action.
|
|
340
|
+
"guide",
|
|
341
|
+
// Search-index rebuild is a long-running maintenance command not exposed as an SDK action.
|
|
342
|
+
"reindex",
|
|
343
|
+
// Workspace normalization is a maintenance command not exposed as an SDK action.
|
|
344
|
+
"normalize",
|
|
345
|
+
// Dedupe audit is a governance report not exposed as an SDK action.
|
|
346
|
+
"dedupe-audit",
|
|
347
|
+
// Comments audit is a governance report not exposed as an SDK action.
|
|
348
|
+
"comments-audit",
|
|
349
|
+
// Calendar rendering is a presentation command not exposed as an SDK action.
|
|
350
|
+
"calendar",
|
|
351
|
+
// Test-runs history is not exposed as an SDK action.
|
|
352
|
+
"test-runs",
|
|
353
|
+
// `pm templates list/show` is not a native SDK action.
|
|
354
|
+
"templates",
|
|
355
|
+
// `PmClient.run("plan", {options:{subcommand,id}})` does not accept the plan id
|
|
356
|
+
// as an option key (only the typed convenience methods `planShow(id)` /
|
|
357
|
+
// `planAddStep(id,...)` do). Converting all ~17 plan routes to typed methods is
|
|
358
|
+
// out of scope for the spawn-removal hot path; plan stays on the spawn fallback.
|
|
359
|
+
"plan",
|
|
360
|
+
// pm-graph is an extension that emits its own JSON to stdout, which the graph
|
|
361
|
+
// routes `JSON.parse` directly. Running it through `PmClient.run` would change
|
|
362
|
+
// the result shape the routes depend on, so it stays on the spawn path.
|
|
363
|
+
"pm-graph",
|
|
364
|
+
]);
|
|
365
|
+
/**
|
|
366
|
+
* Per-positional option key mapping for actions whose CLI form takes positional
|
|
367
|
+
* arguments (e.g. `pm get <id>`, `pm restore <id> <target>`). `PmClient.run`
|
|
368
|
+
* takes a single options bag, so positionals must be mapped onto named keys.
|
|
369
|
+
* Actions not listed here take options only (positionals are not expected).
|
|
370
|
+
*/
|
|
371
|
+
const POSITIONAL_KEYS = {
|
|
372
|
+
init: ["prefix"],
|
|
373
|
+
get: ["id"],
|
|
374
|
+
update: ["id"],
|
|
375
|
+
close: ["id", "reason"],
|
|
376
|
+
delete: ["id"],
|
|
377
|
+
comments: ["id", "add"],
|
|
378
|
+
notes: ["id", "add"],
|
|
379
|
+
learnings: ["id", "add"],
|
|
380
|
+
test: ["id", "add"],
|
|
381
|
+
files: ["id"],
|
|
382
|
+
docs: ["id"],
|
|
383
|
+
deps: ["id"],
|
|
384
|
+
append: ["id", "body"],
|
|
385
|
+
restore: ["id", "target"],
|
|
386
|
+
claim: ["id"],
|
|
387
|
+
release: ["id"],
|
|
388
|
+
copy: ["id"],
|
|
389
|
+
focus: ["id"],
|
|
390
|
+
"start-task": ["id"],
|
|
391
|
+
"pause-task": ["id"],
|
|
392
|
+
"close-task": ["id", "reason"],
|
|
393
|
+
history: ["id"],
|
|
394
|
+
config: ["scope", "configAction", "key", "value"],
|
|
395
|
+
};
|
|
396
|
+
const PM_CLIENT_CACHE_MAX = positiveInteger(process.env.PM_WEB_PM_CLIENT_CACHE_MAX, 256);
|
|
397
|
+
/** Bounded least-recently-used `PmClient` cache keyed by workspace pm-root. */
|
|
398
|
+
const pmClientCache = new Map();
|
|
399
|
+
/**
|
|
400
|
+
* Return a cached {@link PmClient} for a workspace pm-root, creating one on
|
|
401
|
+
* first use. The SDK owns extension activation and serialization internally;
|
|
402
|
+
* caching avoids reconstructing the immutable workspace defaults while each
|
|
403
|
+
* call still receives the SDK's current extension snapshot. Author identity is
|
|
404
|
+
* resolved by the SDK's default detection, preserving prior CLI behaviour.
|
|
405
|
+
*/
|
|
406
|
+
export function getPmClient(pmRoot) {
|
|
407
|
+
let client = pmClientCache.get(pmRoot);
|
|
408
|
+
if (client) {
|
|
409
|
+
pmClientCache.delete(pmRoot);
|
|
410
|
+
pmClientCache.set(pmRoot, client);
|
|
411
|
+
return client;
|
|
412
|
+
}
|
|
413
|
+
if (pmClientCache.size >= PM_CLIENT_CACHE_MAX) {
|
|
414
|
+
const leastRecentlyUsed = pmClientCache.keys().next().value;
|
|
415
|
+
if (typeof leastRecentlyUsed === "string")
|
|
416
|
+
pmClientCache.delete(leastRecentlyUsed);
|
|
417
|
+
}
|
|
418
|
+
client = new PmClient({
|
|
419
|
+
pmRoot,
|
|
420
|
+
cwd: path.dirname(path.dirname(pmRoot)),
|
|
421
|
+
});
|
|
422
|
+
pmClientCache.set(pmRoot, client);
|
|
423
|
+
return client;
|
|
424
|
+
}
|
|
425
|
+
/** Drop a cached client when its workspace is deleted. */
|
|
426
|
+
export function evictPmClient(pmRoot) {
|
|
427
|
+
pmClientCache.delete(pmRoot);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Read a workspace's parsed `settings.json` for the search-tuning resolvers.
|
|
431
|
+
* Returns `{}` when absent so resolvers fall back to their built-in defaults.
|
|
432
|
+
*/
|
|
433
|
+
export function readPmSettings(userId, slug) {
|
|
434
|
+
const settingsPath = path.join(getProjectDir(userId, slug), ".agents", "pm", "settings.json");
|
|
435
|
+
try {
|
|
436
|
+
return JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return {};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Convert a kebab-case CLI flag name to the camelCase SDK option key.
|
|
444
|
+
*
|
|
445
|
+
* `PmClient.run` accepts single-word flag names as-is but **silently ignores**
|
|
446
|
+
* multi-word kebab names (e.g. `dry-run`, `include-body`, `filter-status`),
|
|
447
|
+
* which is catastrophic for boolean guards like `--dry-run`. The SDK option
|
|
448
|
+
* contract is camelCase, so `--filter-deadline-before` must become
|
|
449
|
+
* `filterDeadlineBefore`. Single-word names pass through unchanged.
|
|
450
|
+
*/
|
|
451
|
+
function kebabToCamel(flag) {
|
|
452
|
+
if (!flag.includes("-"))
|
|
453
|
+
return flag;
|
|
454
|
+
return flag.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Derive boolean option arity from the SDK's canonical action-scoped tool
|
|
458
|
+
* schema. This keeps the adapter aligned when pm adds an option and lets string
|
|
459
|
+
* values begin with `--` without being mistaken for another flag.
|
|
460
|
+
*/
|
|
461
|
+
function booleanOptionsByAction() {
|
|
462
|
+
const result = new Map();
|
|
463
|
+
const branches = PM_TOOL_PARAMETERS_SCHEMA.oneOf ?? [];
|
|
464
|
+
for (const branch of branches) {
|
|
465
|
+
const properties = branch.properties ?? {};
|
|
466
|
+
const action = properties["action"]?.const;
|
|
467
|
+
if (typeof action !== "string")
|
|
468
|
+
continue;
|
|
469
|
+
const keys = Object.entries(properties)
|
|
470
|
+
.filter(([, property]) => property.type === "boolean")
|
|
471
|
+
.map(([key]) => key);
|
|
472
|
+
result.set(action, new Set(keys));
|
|
473
|
+
}
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
const BOOLEAN_OPTIONS_BY_ACTION = booleanOptionsByAction();
|
|
477
|
+
/**
|
|
478
|
+
* Parse a CLI-style argv tail (without the leading `--json` injected by the
|
|
479
|
+
* spawn path) into the action name, a camelCase options bag, and positionals.
|
|
480
|
+
* `--json` is dropped: the SDK returns structured objects, never JSON text.
|
|
481
|
+
*/
|
|
482
|
+
function parsePmArgs(args) {
|
|
483
|
+
const action = args[0] ?? "";
|
|
484
|
+
const booleanOptions = BOOLEAN_OPTIONS_BY_ACTION.get(action) ?? new Set();
|
|
485
|
+
const options = {};
|
|
486
|
+
const positionals = [];
|
|
487
|
+
for (let i = 1; i < args.length; i++) {
|
|
488
|
+
const arg = args[i];
|
|
489
|
+
if (arg === "--json")
|
|
490
|
+
continue;
|
|
491
|
+
if (arg === "--") {
|
|
492
|
+
while (++i < args.length)
|
|
493
|
+
positionals.push(args[i]);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
if (arg.startsWith("--no-")) {
|
|
497
|
+
options[kebabToCamel(arg.slice(5))] = false;
|
|
498
|
+
}
|
|
499
|
+
else if (arg.startsWith("--")) {
|
|
500
|
+
const equalsIndex = arg.indexOf("=");
|
|
501
|
+
const rawFlag = arg.slice(2, equalsIndex === -1 ? undefined : equalsIndex);
|
|
502
|
+
const key = kebabToCamel(rawFlag);
|
|
503
|
+
if (equalsIndex !== -1) {
|
|
504
|
+
options[key] = arg.slice(equalsIndex + 1);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (booleanOptions.has(key)) {
|
|
508
|
+
options[key] = true;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const next = args[i + 1];
|
|
512
|
+
if (next !== undefined) {
|
|
513
|
+
options[key] = next;
|
|
514
|
+
i++;
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
options[key] = true;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
positionals.push(arg);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { action, options, positionals };
|
|
525
|
+
}
|
|
526
|
+
/** Merge mapped positionals onto the options bag for `PmClient.run`. */
|
|
527
|
+
function withPositionals(action, positionals, options) {
|
|
528
|
+
const keys = POSITIONAL_KEYS[action];
|
|
529
|
+
if (!keys)
|
|
530
|
+
return options;
|
|
531
|
+
const merged = { ...options };
|
|
532
|
+
for (let i = 0; i < keys.length && i < positionals.length; i++) {
|
|
533
|
+
merged[keys[i]] = positionals[i];
|
|
534
|
+
}
|
|
535
|
+
return merged;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Dispatch a supported action in-process through {@link PmClient}.
|
|
539
|
+
*
|
|
540
|
+
* Supported actions go through the generic `client.run(action, {options})`
|
|
541
|
+
* dispatcher, which accepts native and extension-contributed actions alike.
|
|
542
|
+
*
|
|
543
|
+
* The result object is returned both as `parsed` (structured) and stringified
|
|
544
|
+
* into `stdout`, so routes that read either field keep working.
|
|
545
|
+
*/
|
|
546
|
+
async function runPmInProcess(opts, dir) {
|
|
547
|
+
const pmRoot = path.join(dir, ".agents", "pm");
|
|
548
|
+
const client = getPmClient(pmRoot);
|
|
549
|
+
const { action, options, positionals } = parsePmArgs(opts.args);
|
|
550
|
+
try {
|
|
551
|
+
const result = await client.run(action, {
|
|
552
|
+
options: withPositionals(action, positionals, options),
|
|
553
|
+
});
|
|
554
|
+
const stdout = JSON.stringify(result) ?? "";
|
|
555
|
+
return { stdout, stderr: "", ok: true, parsed: result };
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
if ((err instanceof PmCliError || isPmCliExpectedError(err)) &&
|
|
559
|
+
err.message.startsWith("Unsupported native pm action:")) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
if (err instanceof PmCliError || isPmCliExpectedError(err)) {
|
|
563
|
+
return { stdout: "", stderr: err.message, ok: false, parsed: undefined, exitCode: err.exitCode };
|
|
564
|
+
}
|
|
565
|
+
return { stdout: "", stderr: err instanceof Error ? err.message : String(err), ok: false, parsed: undefined };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
281
568
|
export async function runPm(opts) {
|
|
282
569
|
const dir = getProjectDir(opts.userId, opts.slug);
|
|
570
|
+
const action = opts.args[0] ?? "";
|
|
571
|
+
// Supported actions run in-process through the cached PmClient — no spawn.
|
|
572
|
+
if (!SPAWN_FALLBACK_ACTIONS.has(action) &&
|
|
573
|
+
opts.input === undefined &&
|
|
574
|
+
opts.timeoutMs === undefined) {
|
|
575
|
+
const sdkResult = await runPmInProcess(opts, dir);
|
|
576
|
+
if (sdkResult)
|
|
577
|
+
return sdkResult;
|
|
578
|
+
}
|
|
579
|
+
// Fallback: spawn the pm binary for actions the SDK dispatcher cannot serve.
|
|
283
580
|
const args = opts.jsonOutput ? ["--json", ...opts.args] : opts.args;
|
|
284
581
|
const result = await runSerialized(dir, () => runProcess(dir, args, {
|
|
285
582
|
input: opts.input,
|
|
286
583
|
timeoutMs: opts.timeoutMs,
|
|
287
584
|
env: { PM_GRAPH_PROJECT_KEY: `${opts.userId}:${opts.slug}` },
|
|
288
585
|
}));
|
|
289
|
-
const { stdout, stderr, ok } = result;
|
|
586
|
+
const { stdout, stderr, ok, exitCode } = result;
|
|
290
587
|
let parsed;
|
|
291
588
|
if (opts.jsonOutput && ok && stdout) {
|
|
292
589
|
try {
|
|
@@ -296,7 +593,7 @@ export async function runPm(opts) {
|
|
|
296
593
|
parsed = { raw: stdout };
|
|
297
594
|
}
|
|
298
595
|
}
|
|
299
|
-
return { stdout, stderr, ok, parsed };
|
|
596
|
+
return { stdout, stderr, ok, parsed, exitCode };
|
|
300
597
|
}
|
|
301
598
|
/**
|
|
302
599
|
* Reconstruct a single item at a one-based version or ISO timestamp using the
|
|
@@ -317,6 +614,7 @@ export async function runGetItemAt(userId, slug, itemId, ref) {
|
|
|
317
614
|
}
|
|
318
615
|
export function deleteProjectDir(userId, slug) {
|
|
319
616
|
const dir = getProjectDir(userId, slug);
|
|
617
|
+
evictPmClient(path.join(dir, ".agents", "pm"));
|
|
320
618
|
if (fs.existsSync(dir)) {
|
|
321
619
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
322
620
|
}
|