@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2
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/AGENTS.md +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repo OpenSpec change-grouping store.
|
|
3
|
+
*
|
|
4
|
+
* On-disk shape: `<cwd>/openspec/groups/groups.json` containing
|
|
5
|
+
* `{ schemaVersion, groups, assignments }`. File is opt-in: absent → empty
|
|
6
|
+
* default. First write creates the directory atomically.
|
|
7
|
+
*
|
|
8
|
+
* Concurrency model:
|
|
9
|
+
* - Reads stat the file (microseconds, OS-cached) and short-circuit on a
|
|
10
|
+
* `(mtimeMs, size)` cache hit. Concurrent reads in the same tick share
|
|
11
|
+
* a single in-flight promise to avoid stampedes.
|
|
12
|
+
* - Writes serialize per-cwd via a FIFO promise chain. Inside the
|
|
13
|
+
* critical section the store re-stats before rename; on mtime drift
|
|
14
|
+
* it re-reads, re-applies the mutator, and retries once. A second
|
|
15
|
+
* drift surfaces as `ConcurrentEditError` (HTTP 409 at the route).
|
|
16
|
+
* - After every successful write a 100 ms trailing debounce schedules
|
|
17
|
+
* one `subscribe()` callback per cwd, regardless of write rate.
|
|
18
|
+
*
|
|
19
|
+
* See change: add-openspec-change-grouping (tasks 2.1–2.17).
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
OPENSPEC_GROUPS_SCHEMA_VERSION,
|
|
25
|
+
type OpenSpecData,
|
|
26
|
+
type OpenSpecGroup,
|
|
27
|
+
type OpenSpecGroupsFile,
|
|
28
|
+
} from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pure helper. Returns a new `OpenSpecData` with every `OpenSpecChange.groupId`
|
|
32
|
+
* populated from the provided assignments map (`changeName → groupId`).
|
|
33
|
+
* Changes without an entry get `groupId: null` (Ungrouped). Used by
|
|
34
|
+
* `directory-service` after `buildOpenSpecData` and before broadcast so all
|
|
35
|
+
* clients see a single joined view.
|
|
36
|
+
*
|
|
37
|
+
* See change: add-openspec-change-grouping (tasks 4.1–4.2).
|
|
38
|
+
*/
|
|
39
|
+
export function joinGroupIdsToOpenSpecData(
|
|
40
|
+
data: OpenSpecData,
|
|
41
|
+
assignments: Record<string, string>,
|
|
42
|
+
): OpenSpecData {
|
|
43
|
+
return {
|
|
44
|
+
...data,
|
|
45
|
+
changes: data.changes.map((c) => ({
|
|
46
|
+
...c,
|
|
47
|
+
groupId: assignments[c.name] ?? null,
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Errors ───────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export class ConcurrentEditError extends Error {
|
|
55
|
+
/** Current on-disk payload at the time the conflict was detected. */
|
|
56
|
+
readonly current: OpenSpecGroupsFile;
|
|
57
|
+
constructor(current: OpenSpecGroupsFile) {
|
|
58
|
+
super("Concurrent edit detected");
|
|
59
|
+
this.name = "ConcurrentEditError";
|
|
60
|
+
this.current = current;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class UnsupportedSchemaVersionError extends Error {
|
|
65
|
+
readonly version: unknown;
|
|
66
|
+
constructor(version: unknown, message?: string) {
|
|
67
|
+
super(message ?? `unsupported schema version: ${String(version)}`);
|
|
68
|
+
this.name = "UnsupportedSchemaVersionError";
|
|
69
|
+
this.version = version;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class GroupNotFoundError extends Error {
|
|
74
|
+
readonly id: string;
|
|
75
|
+
constructor(id: string) {
|
|
76
|
+
super(`Group not found: ${id}`);
|
|
77
|
+
this.name = "GroupNotFoundError";
|
|
78
|
+
this.id = id;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class UnknownGroupIdError extends Error {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
constructor(id: string) {
|
|
85
|
+
super(`Unknown groupId: ${id}`);
|
|
86
|
+
this.name = "UnknownGroupIdError";
|
|
87
|
+
this.id = id;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Public surface ───────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export interface OpenSpecGroupStoreOptions {
|
|
94
|
+
/** Trailing-debounce window for subscriber callbacks in ms. Default 100. */
|
|
95
|
+
debounceMs?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Test-only hook fired AFTER the temp file is staged, BEFORE the rename.
|
|
98
|
+
* Tests use this to simulate hand-edit / `git pull` races. Production
|
|
99
|
+
* MUST leave this undefined.
|
|
100
|
+
*/
|
|
101
|
+
__testHookBeforeRename?: (cwd: string) => Promise<void> | void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface OpenSpecGroupStore {
|
|
105
|
+
read(cwd: string): Promise<OpenSpecGroupsFile>;
|
|
106
|
+
createGroup(cwd: string, body: { name: string; color?: string }): Promise<OpenSpecGroup>;
|
|
107
|
+
updateGroup(
|
|
108
|
+
cwd: string,
|
|
109
|
+
id: string,
|
|
110
|
+
body: { name?: string; color?: string; order?: number },
|
|
111
|
+
): Promise<OpenSpecGroup>;
|
|
112
|
+
deleteGroup(cwd: string, id: string): Promise<void>;
|
|
113
|
+
setAssignment(cwd: string, changeName: string, groupId: string | null): Promise<void>;
|
|
114
|
+
/**
|
|
115
|
+
* Subscribe to debounced post-write broadcasts. Returns an unsubscribe fn.
|
|
116
|
+
* The callback receives the cwd plus the latest `{ groups, assignments }`.
|
|
117
|
+
*/
|
|
118
|
+
subscribe(
|
|
119
|
+
cb: (cwd: string, payload: { groups: OpenSpecGroup[]; assignments: Record<string, string> }) => void,
|
|
120
|
+
): () => void;
|
|
121
|
+
/** Flushes pending broadcasts and clears caches. Tests + shutdown. */
|
|
122
|
+
dispose(): void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Internal cache shape ─────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
interface CacheEntry {
|
|
128
|
+
mtimeMs: number;
|
|
129
|
+
size: number;
|
|
130
|
+
data: OpenSpecGroupsFile | undefined;
|
|
131
|
+
inFlight?: Promise<OpenSpecGroupsFile>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_DEBOUNCE_MS = 100;
|
|
135
|
+
|
|
136
|
+
function emptyFile(): OpenSpecGroupsFile {
|
|
137
|
+
return { schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION, groups: [], assignments: {} };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pathFor(cwd: string): string {
|
|
141
|
+
return path.join(cwd, "openspec", "groups", "groups.json");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function dirFor(cwd: string): string {
|
|
145
|
+
return path.join(cwd, "openspec", "groups");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function slugify(name: string): string {
|
|
149
|
+
const base = name
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
.normalize("NFKD")
|
|
152
|
+
.replace(/[\u0300-\u036f]/g, "") // strip accents
|
|
153
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
154
|
+
.replace(/^-+|-+$/g, "");
|
|
155
|
+
return base.length > 0 ? base : "group";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function uniqueSlug(base: string, existing: ReadonlySet<string>): string {
|
|
159
|
+
if (!existing.has(base)) return base;
|
|
160
|
+
let n = 2;
|
|
161
|
+
while (existing.has(`${base}-${n}`)) n++;
|
|
162
|
+
return `${base}-${n}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function validateSchemaVersion(parsed: unknown): asserts parsed is OpenSpecGroupsFile {
|
|
166
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
167
|
+
throw new UnsupportedSchemaVersionError(undefined, "groups.json must be an object");
|
|
168
|
+
}
|
|
169
|
+
const v = (parsed as { schemaVersion?: unknown }).schemaVersion;
|
|
170
|
+
if (v === undefined) {
|
|
171
|
+
throw new UnsupportedSchemaVersionError(undefined, "missing schemaVersion field");
|
|
172
|
+
}
|
|
173
|
+
if (v !== OPENSPEC_GROUPS_SCHEMA_VERSION) {
|
|
174
|
+
throw new UnsupportedSchemaVersionError(v);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Re-pack `order` values to contiguous `0..N-1` while preserving sort order. */
|
|
179
|
+
function normalizeOrders(groups: OpenSpecGroup[]): OpenSpecGroup[] {
|
|
180
|
+
const sorted = [...groups].sort((a, b) => a.order - b.order);
|
|
181
|
+
return sorted.map((g, i) => ({ ...g, order: i }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Factory ──────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export function createOpenSpecGroupStore(
|
|
187
|
+
opts: OpenSpecGroupStoreOptions = {},
|
|
188
|
+
): OpenSpecGroupStore {
|
|
189
|
+
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
190
|
+
const hook = opts.__testHookBeforeRename;
|
|
191
|
+
|
|
192
|
+
const cache = new Map<string, CacheEntry>();
|
|
193
|
+
const writeMutex = new Map<string, Promise<void>>();
|
|
194
|
+
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
195
|
+
type Subscriber = (
|
|
196
|
+
cwd: string,
|
|
197
|
+
payload: { groups: OpenSpecGroup[]; assignments: Record<string, string> },
|
|
198
|
+
) => void;
|
|
199
|
+
const subscribers = new Set<Subscriber>();
|
|
200
|
+
|
|
201
|
+
async function tryStat(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
|
|
202
|
+
try {
|
|
203
|
+
const s = await fs.stat(filePath);
|
|
204
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
205
|
+
} catch (err: any) {
|
|
206
|
+
if (err?.code === "ENOENT") return null;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read the file via the mtime-gated cache. Returns the empty default when
|
|
213
|
+
* absent. Throws `UnsupportedSchemaVersionError` on bad version.
|
|
214
|
+
*/
|
|
215
|
+
async function read(cwd: string): Promise<OpenSpecGroupsFile> {
|
|
216
|
+
const filePath = pathFor(cwd);
|
|
217
|
+
|
|
218
|
+
// Short-circuit a concurrent in-flight read.
|
|
219
|
+
const existing = cache.get(cwd);
|
|
220
|
+
if (existing?.inFlight) return existing.inFlight;
|
|
221
|
+
|
|
222
|
+
const inFlight = (async (): Promise<OpenSpecGroupsFile> => {
|
|
223
|
+
const stat = await tryStat(filePath);
|
|
224
|
+
if (!stat) {
|
|
225
|
+
cache.delete(cwd);
|
|
226
|
+
return emptyFile();
|
|
227
|
+
}
|
|
228
|
+
const cached = cache.get(cwd);
|
|
229
|
+
if (cached?.data && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
230
|
+
return cached.data;
|
|
231
|
+
}
|
|
232
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
233
|
+
let parsed: unknown;
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(raw);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
throw new UnsupportedSchemaVersionError(undefined, `groups.json parse error: ${(err as Error).message}`);
|
|
238
|
+
}
|
|
239
|
+
validateSchemaVersion(parsed);
|
|
240
|
+
const data = parsed as OpenSpecGroupsFile;
|
|
241
|
+
cache.set(cwd, { mtimeMs: stat.mtimeMs, size: stat.size, data });
|
|
242
|
+
return data;
|
|
243
|
+
})();
|
|
244
|
+
|
|
245
|
+
// Stash the in-flight promise so concurrent callers share it.
|
|
246
|
+
const slot: CacheEntry = existing ?? { mtimeMs: 0, size: 0, data: undefined };
|
|
247
|
+
slot.inFlight = inFlight;
|
|
248
|
+
cache.set(cwd, slot);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
return await inFlight;
|
|
252
|
+
} finally {
|
|
253
|
+
const e = cache.get(cwd);
|
|
254
|
+
if (e?.inFlight === inFlight) {
|
|
255
|
+
delete e.inFlight;
|
|
256
|
+
// If the read produced no data (e.g. file vanished mid-read), purge
|
|
257
|
+
// the placeholder slot rather than leak `mtimeMs: 0` forever.
|
|
258
|
+
if (!e.data) cache.delete(cwd);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Run a per-cwd mutation under the FIFO mutex. The mutator receives the
|
|
265
|
+
* current file payload and returns a fresh payload. Implements the
|
|
266
|
+
* mtime-recheck-before-rename + 1-shot retry on race.
|
|
267
|
+
*/
|
|
268
|
+
async function mutate<T>(
|
|
269
|
+
cwd: string,
|
|
270
|
+
mutator: (current: OpenSpecGroupsFile) => { next: OpenSpecGroupsFile; result: T },
|
|
271
|
+
): Promise<T> {
|
|
272
|
+
const prev = writeMutex.get(cwd) ?? Promise.resolve();
|
|
273
|
+
let release!: () => void;
|
|
274
|
+
const slot = new Promise<void>((resolve) => {
|
|
275
|
+
release = resolve;
|
|
276
|
+
});
|
|
277
|
+
writeMutex.set(cwd, prev.then(() => slot));
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await prev;
|
|
281
|
+
// Try once, then retry once on race.
|
|
282
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
283
|
+
const filePath = pathFor(cwd);
|
|
284
|
+
const preStat = await tryStat(filePath);
|
|
285
|
+
const preMtime = preStat?.mtimeMs ?? null;
|
|
286
|
+
const preSize = preStat?.size ?? null;
|
|
287
|
+
const current = await read(cwd);
|
|
288
|
+
const { next, result } = mutator(current);
|
|
289
|
+
const tmpPath = filePath + ".tmp";
|
|
290
|
+
await fs.mkdir(dirFor(cwd), { recursive: true });
|
|
291
|
+
const serialized = JSON.stringify(next, null, 2) + "\n";
|
|
292
|
+
await fs.writeFile(tmpPath, serialized);
|
|
293
|
+
if (hook) {
|
|
294
|
+
await hook(cwd);
|
|
295
|
+
}
|
|
296
|
+
// Re-stat the original; if mtime/size changed since pre-read, race.
|
|
297
|
+
const postStat = await tryStat(filePath);
|
|
298
|
+
const postMtime = postStat?.mtimeMs ?? null;
|
|
299
|
+
const postSize = postStat?.size ?? null;
|
|
300
|
+
const raced = preMtime !== postMtime || preSize !== postSize;
|
|
301
|
+
if (raced) {
|
|
302
|
+
// Drop temp; retry once, else throw.
|
|
303
|
+
await fs.rm(tmpPath, { force: true });
|
|
304
|
+
if (attempt === 0) continue;
|
|
305
|
+
// Surface current payload for HTTP 409.
|
|
306
|
+
// Force a fresh read by invalidating the cache.
|
|
307
|
+
cache.delete(cwd);
|
|
308
|
+
const currentFile = await read(cwd);
|
|
309
|
+
throw new ConcurrentEditError(currentFile);
|
|
310
|
+
}
|
|
311
|
+
await fs.rename(tmpPath, filePath);
|
|
312
|
+
// Update cache directly with the new file's stat.
|
|
313
|
+
const finalStat = await fs.stat(filePath);
|
|
314
|
+
cache.set(cwd, {
|
|
315
|
+
mtimeMs: finalStat.mtimeMs,
|
|
316
|
+
size: finalStat.size,
|
|
317
|
+
data: next,
|
|
318
|
+
});
|
|
319
|
+
scheduleBroadcast(cwd, next);
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
// Unreachable.
|
|
323
|
+
throw new ConcurrentEditError(await read(cwd));
|
|
324
|
+
} finally {
|
|
325
|
+
release();
|
|
326
|
+
// Clean up exhausted mutex slots so the map doesn't leak per-cwd.
|
|
327
|
+
// Once the chain is fully drained, drop the entry.
|
|
328
|
+
// (No-op when newer writes are queued behind us.)
|
|
329
|
+
Promise.resolve(writeMutex.get(cwd)).then(() => {
|
|
330
|
+
// If still pointing at our slot's tail, drop.
|
|
331
|
+
if (writeMutex.get(cwd) === prev.then(() => slot)) writeMutex.delete(cwd);
|
|
332
|
+
}).catch(() => {});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function scheduleBroadcast(cwd: string, file: OpenSpecGroupsFile): void {
|
|
337
|
+
if (subscribers.size === 0) return;
|
|
338
|
+
const existing = debounceTimers.get(cwd);
|
|
339
|
+
if (existing) clearTimeout(existing);
|
|
340
|
+
const timer = setTimeout(() => {
|
|
341
|
+
debounceTimers.delete(cwd);
|
|
342
|
+
// Always emit the freshest cached payload for this cwd, not the file
|
|
343
|
+
// captured when the timer was scheduled — matters for coalesced bursts.
|
|
344
|
+
const latest = cache.get(cwd)?.data ?? file;
|
|
345
|
+
const payload = { groups: latest.groups, assignments: latest.assignments };
|
|
346
|
+
for (const cb of subscribers) {
|
|
347
|
+
try {
|
|
348
|
+
cb(cwd, payload);
|
|
349
|
+
} catch {
|
|
350
|
+
/* subscriber threw — swallow so other subs still fire */
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}, debounceMs);
|
|
354
|
+
debounceTimers.set(cwd, timer);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Public methods ───────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
async function createGroup(
|
|
360
|
+
cwd: string,
|
|
361
|
+
body: { name: string; color?: string },
|
|
362
|
+
): Promise<OpenSpecGroup> {
|
|
363
|
+
return mutate(cwd, (current) => {
|
|
364
|
+
const existingIds = new Set(current.groups.map((g) => g.id));
|
|
365
|
+
const id = uniqueSlug(slugify(body.name), existingIds);
|
|
366
|
+
const newGroup: OpenSpecGroup = {
|
|
367
|
+
id,
|
|
368
|
+
name: body.name,
|
|
369
|
+
...(body.color !== undefined ? { color: body.color } : {}),
|
|
370
|
+
order: current.groups.length,
|
|
371
|
+
};
|
|
372
|
+
const next: OpenSpecGroupsFile = {
|
|
373
|
+
...current,
|
|
374
|
+
schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
|
|
375
|
+
groups: [...current.groups, newGroup],
|
|
376
|
+
};
|
|
377
|
+
return { next, result: newGroup };
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function updateGroup(
|
|
382
|
+
cwd: string,
|
|
383
|
+
id: string,
|
|
384
|
+
body: { name?: string; color?: string; order?: number },
|
|
385
|
+
): Promise<OpenSpecGroup> {
|
|
386
|
+
return mutate(cwd, (current) => {
|
|
387
|
+
const target = current.groups.find((g) => g.id === id);
|
|
388
|
+
if (!target) throw new GroupNotFoundError(id);
|
|
389
|
+
|
|
390
|
+
// Apply scalar updates first (name, color).
|
|
391
|
+
const updatedTarget: OpenSpecGroup = {
|
|
392
|
+
...target,
|
|
393
|
+
...(body.name !== undefined ? { name: body.name } : {}),
|
|
394
|
+
...(body.color !== undefined ? { color: body.color } : {}),
|
|
395
|
+
...(body.order !== undefined ? { order: body.order } : {}),
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const replaced = current.groups.map((g) => (g.id === id ? updatedTarget : g));
|
|
399
|
+
|
|
400
|
+
// If order was touched, normalize the whole set to contiguous 0..N-1.
|
|
401
|
+
// Keep `updatedTarget` at its requested slot, push others around it.
|
|
402
|
+
const finalGroups =
|
|
403
|
+
body.order === undefined
|
|
404
|
+
? replaced
|
|
405
|
+
: (() => {
|
|
406
|
+
// Sort: target sits first at its requested order; others keep
|
|
407
|
+
// their relative ordering. Then re-pack indexes.
|
|
408
|
+
const others = replaced.filter((g) => g.id !== id).sort((a, b) => a.order - b.order);
|
|
409
|
+
// Insert target at clamped position.
|
|
410
|
+
const pos = Math.max(0, Math.min(body.order!, others.length));
|
|
411
|
+
const merged = [...others];
|
|
412
|
+
merged.splice(pos, 0, updatedTarget);
|
|
413
|
+
return merged.map((g, i) => ({ ...g, order: i }));
|
|
414
|
+
})();
|
|
415
|
+
|
|
416
|
+
const next: OpenSpecGroupsFile = {
|
|
417
|
+
...current,
|
|
418
|
+
schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
|
|
419
|
+
groups: finalGroups,
|
|
420
|
+
};
|
|
421
|
+
const finalTarget = finalGroups.find((g) => g.id === id)!;
|
|
422
|
+
return { next, result: finalTarget };
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function deleteGroup(cwd: string, id: string): Promise<void> {
|
|
427
|
+
return mutate(cwd, (current) => {
|
|
428
|
+
const exists = current.groups.some((g) => g.id === id);
|
|
429
|
+
if (!exists) throw new GroupNotFoundError(id);
|
|
430
|
+
const remaining = normalizeOrders(current.groups.filter((g) => g.id !== id));
|
|
431
|
+
// Cascade: remove any assignment pointing at the deleted group.
|
|
432
|
+
const trimmed: Record<string, string> = {};
|
|
433
|
+
for (const [k, v] of Object.entries(current.assignments)) {
|
|
434
|
+
if (v !== id) trimmed[k] = v;
|
|
435
|
+
}
|
|
436
|
+
const next: OpenSpecGroupsFile = {
|
|
437
|
+
schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
|
|
438
|
+
groups: remaining,
|
|
439
|
+
assignments: trimmed,
|
|
440
|
+
};
|
|
441
|
+
return { next, result: undefined };
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function setAssignment(
|
|
446
|
+
cwd: string,
|
|
447
|
+
changeName: string,
|
|
448
|
+
groupId: string | null,
|
|
449
|
+
): Promise<void> {
|
|
450
|
+
return mutate(cwd, (current) => {
|
|
451
|
+
if (groupId !== null && !current.groups.some((g) => g.id === groupId)) {
|
|
452
|
+
throw new UnknownGroupIdError(groupId);
|
|
453
|
+
}
|
|
454
|
+
const next: OpenSpecGroupsFile = {
|
|
455
|
+
...current,
|
|
456
|
+
schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
|
|
457
|
+
assignments: { ...current.assignments },
|
|
458
|
+
};
|
|
459
|
+
if (groupId === null) {
|
|
460
|
+
delete next.assignments[changeName];
|
|
461
|
+
} else {
|
|
462
|
+
next.assignments[changeName] = groupId;
|
|
463
|
+
}
|
|
464
|
+
return { next, result: undefined };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function subscribe(cb: Subscriber): () => void {
|
|
469
|
+
subscribers.add(cb);
|
|
470
|
+
return () => subscribers.delete(cb);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function dispose(): void {
|
|
474
|
+
for (const t of debounceTimers.values()) clearTimeout(t);
|
|
475
|
+
debounceTimers.clear();
|
|
476
|
+
subscribers.clear();
|
|
477
|
+
cache.clear();
|
|
478
|
+
writeMutex.clear();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
read,
|
|
483
|
+
createGroup,
|
|
484
|
+
updateGroup,
|
|
485
|
+
deleteGroup,
|
|
486
|
+
setAssignment,
|
|
487
|
+
subscribe,
|
|
488
|
+
dispose,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps `spawnToken` (server-minted UUID) → `requestId` (client-minted UUID).
|
|
3
|
+
*
|
|
4
|
+
* Recorded by `handleSpawnSession` / `handleResumeSession` when the browser
|
|
5
|
+
* supplied a `requestId`. Consumed by `event-wiring.ts` after a successful
|
|
6
|
+
* `linkByToken` so the eventual `session_added` broadcast can carry
|
|
7
|
+
* `spawnRequestId` for client-side auto-select / placeholder dismissal.
|
|
8
|
+
*
|
|
9
|
+
* In-memory only. 60s TTL aligned with `spawn-register-watchdog` recovery
|
|
10
|
+
* window so late registers can still surface the correlation.
|
|
11
|
+
*
|
|
12
|
+
* See change: spawn-correlation-token.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
interface Entry {
|
|
18
|
+
requestId: string;
|
|
19
|
+
recordedAt: number;
|
|
20
|
+
timer: ReturnType<typeof setTimeout>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PendingClientCorrelations {
|
|
24
|
+
/** Record `spawnToken → requestId` mapping. Overwrites any prior entry for the same token. */
|
|
25
|
+
record(spawnToken: string, requestId: string): void;
|
|
26
|
+
/** Consume the requestId for a spawnToken, or undefined if none / expired. */
|
|
27
|
+
consume(spawnToken: string): string | undefined;
|
|
28
|
+
/** Drop all entries (server shutdown / tests). */
|
|
29
|
+
dispose(): void;
|
|
30
|
+
/** Number of tracked entries (for tests). */
|
|
31
|
+
size(): number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PendingClientCorrelationsOptions {
|
|
35
|
+
ttlMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createPendingClientCorrelations(
|
|
39
|
+
options?: PendingClientCorrelationsOptions,
|
|
40
|
+
): PendingClientCorrelations {
|
|
41
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
42
|
+
const store = new Map<string, Entry>();
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
record(spawnToken: string, requestId: string): void {
|
|
46
|
+
if (!spawnToken || !requestId) return;
|
|
47
|
+
const prior = store.get(spawnToken);
|
|
48
|
+
if (prior) clearTimeout(prior.timer);
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
store.delete(spawnToken);
|
|
51
|
+
}, ttlMs);
|
|
52
|
+
store.set(spawnToken, { requestId, recordedAt: Date.now(), timer });
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
consume(spawnToken: string): string | undefined {
|
|
56
|
+
if (!spawnToken) return undefined;
|
|
57
|
+
const entry = store.get(spawnToken);
|
|
58
|
+
if (!entry) return undefined;
|
|
59
|
+
clearTimeout(entry.timer);
|
|
60
|
+
store.delete(spawnToken);
|
|
61
|
+
return entry.requestId;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
dispose(): void {
|
|
65
|
+
for (const entry of store.values()) clearTimeout(entry.timer);
|
|
66
|
+
store.clear();
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
size(): number {
|
|
70
|
+
return store.size;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tracks pending fork operations to place forked sessions after their parent.
|
|
3
|
+
*
|
|
4
|
+
* Keyed by `spawnToken` (UUID minted at fork time). When the forked session's
|
|
5
|
+
* bridge sends `session_register.spawnToken`, the server consumes the entry
|
|
6
|
+
* and places the new sessionId after `parentSessionId` in the cwd's order.
|
|
7
|
+
*
|
|
8
|
+
* Replaces the prior cwd-FIFO keying which suffered the multi-fork-in-same-cwd
|
|
9
|
+
* race (second fork's `recordFork` would overwrite the first).
|
|
10
|
+
*
|
|
3
11
|
* Entries expire after 30 seconds if not consumed.
|
|
12
|
+
*
|
|
13
|
+
* See change: spawn-correlation-token.
|
|
4
14
|
*/
|
|
5
15
|
|
|
6
16
|
const EXPIRY_MS = 30_000;
|
|
@@ -11,10 +21,10 @@ interface PendingFork {
|
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
export interface PendingForkRegistry {
|
|
14
|
-
/** Record that a fork was initiated from parentSessionId
|
|
15
|
-
recordFork(
|
|
16
|
-
/** Consume
|
|
17
|
-
consumeFork(
|
|
24
|
+
/** Record that a fork was initiated from `parentSessionId`, keyed by the spawn token. */
|
|
25
|
+
recordFork(spawnToken: string, parentSessionId: string): void;
|
|
26
|
+
/** Consume the parent session id for a spawn token, or undefined if none pending. */
|
|
27
|
+
consumeFork(spawnToken: string): string | undefined;
|
|
18
28
|
/** Clear all pending entries and timers. */
|
|
19
29
|
dispose(): void;
|
|
20
30
|
}
|
|
@@ -23,23 +33,25 @@ export function createPendingForkRegistry(): PendingForkRegistry {
|
|
|
23
33
|
const pending = new Map<string, PendingFork>();
|
|
24
34
|
|
|
25
35
|
return {
|
|
26
|
-
recordFork(
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
recordFork(spawnToken: string, parentSessionId: string): void {
|
|
37
|
+
if (!spawnToken) return;
|
|
38
|
+
// Clear any prior entry for the same token (idempotent re-record).
|
|
39
|
+
const existing = pending.get(spawnToken);
|
|
29
40
|
if (existing) {
|
|
30
41
|
clearTimeout(existing.timer);
|
|
31
42
|
}
|
|
32
43
|
const timer = setTimeout(() => {
|
|
33
|
-
pending.delete(
|
|
44
|
+
pending.delete(spawnToken);
|
|
34
45
|
}, EXPIRY_MS);
|
|
35
|
-
pending.set(
|
|
46
|
+
pending.set(spawnToken, { parentSessionId, timer });
|
|
36
47
|
},
|
|
37
48
|
|
|
38
|
-
consumeFork(
|
|
39
|
-
|
|
49
|
+
consumeFork(spawnToken: string): string | undefined {
|
|
50
|
+
if (!spawnToken) return undefined;
|
|
51
|
+
const entry = pending.get(spawnToken);
|
|
40
52
|
if (!entry) return undefined;
|
|
41
53
|
clearTimeout(entry.timer);
|
|
42
|
-
pending.delete(
|
|
54
|
+
pending.delete(spawnToken);
|
|
43
55
|
return entry.parentSessionId;
|
|
44
56
|
},
|
|
45
57
|
|