march-cli 0.1.39 → 0.1.41
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/package.json +1 -1
- package/src/cli/args.mjs +1 -0
- package/src/cli/input/keybindings.mjs +2 -0
- package/src/cli/repl-loop.mjs +4 -0
- package/src/cli/startup/app-runtime.mjs +7 -8
- package/src/cli/tui/output/timeline-block-restore.mjs +45 -0
- package/src/cli/tui/output-buffer.mjs +5 -0
- package/src/cli/tui/tui-input-controller.mjs +16 -0
- package/src/cli/ui.mjs +6 -2
- package/src/cli/workspace/command.mjs +44 -0
- package/src/cli/workspace/output-router.mjs +40 -33
- package/src/cli/workspace/tui-timeline-projection.mjs +179 -0
- package/src/cli/workspace/tui-timeline.mjs +247 -0
- package/src/config/config-json.mjs +20 -0
- package/src/provider/command.mjs +5 -1
- package/src/provider/remove-command.mjs +129 -0
- package/src/session/control/controller-lease.mjs +149 -0
- package/src/session/state/march-session-state.mjs +4 -14
- package/src/session/state/march-session-ui-state.mjs +48 -19
- package/src/workspace/session-restore.mjs +22 -0
- package/src/workspace/supervisor.mjs +150 -36
- package/src/workspace/view-runtime.mjs +40 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createTuiTimelineProjection } from "./tui-timeline-projection.mjs";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MAX_TIMELINE_EVENTS = 4000;
|
|
4
|
+
export const DEFAULT_TIMELINE_PERSIST_DEBOUNCE_MS = 250;
|
|
5
|
+
|
|
6
|
+
export function createTuiTimelineRegistry({
|
|
7
|
+
maxEventsPerTimeline = DEFAULT_MAX_TIMELINE_EVENTS,
|
|
8
|
+
persistDebounceMs = DEFAULT_TIMELINE_PERSIST_DEBOUNCE_MS,
|
|
9
|
+
onPersistTimeline = null,
|
|
10
|
+
} = {}) {
|
|
11
|
+
const timelines = new Map();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
ensure(key, { events = null } = {}) {
|
|
15
|
+
let timeline = timelines.get(key);
|
|
16
|
+
if (!timeline) {
|
|
17
|
+
timeline = createTuiTimelineInstance({
|
|
18
|
+
key,
|
|
19
|
+
maxEvents: maxEventsPerTimeline,
|
|
20
|
+
persistDebounceMs,
|
|
21
|
+
onPersist: onPersistTimeline,
|
|
22
|
+
});
|
|
23
|
+
timelines.set(key, timeline);
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(events)) timeline.hydrateIfEmpty(events);
|
|
26
|
+
return timeline;
|
|
27
|
+
},
|
|
28
|
+
get(key) {
|
|
29
|
+
return timelines.get(key) ?? null;
|
|
30
|
+
},
|
|
31
|
+
has(key) {
|
|
32
|
+
return timelines.has(key);
|
|
33
|
+
},
|
|
34
|
+
clear(key, options) {
|
|
35
|
+
const timeline = this.ensure(key);
|
|
36
|
+
timeline.clear(options);
|
|
37
|
+
return timeline;
|
|
38
|
+
},
|
|
39
|
+
flush(key, reason = "manual") {
|
|
40
|
+
return this.get(key)?.flushPersist(reason) ?? false;
|
|
41
|
+
},
|
|
42
|
+
flushAll(reason = "manual") {
|
|
43
|
+
let flushed = 0;
|
|
44
|
+
for (const timeline of timelines.values()) {
|
|
45
|
+
if (timeline.flushPersist(reason)) flushed += 1;
|
|
46
|
+
}
|
|
47
|
+
return flushed;
|
|
48
|
+
},
|
|
49
|
+
replaceEvents(key, events) {
|
|
50
|
+
const timeline = this.ensure(key);
|
|
51
|
+
timeline.replaceEvents(events);
|
|
52
|
+
return timeline;
|
|
53
|
+
},
|
|
54
|
+
getEvents(key) {
|
|
55
|
+
return this.get(key)?.getEvents() ?? [];
|
|
56
|
+
},
|
|
57
|
+
getBlocks(key) {
|
|
58
|
+
return this.get(key)?.getBlocks() ?? [];
|
|
59
|
+
},
|
|
60
|
+
getEventCount(key) {
|
|
61
|
+
return this.get(key)?.getEventCount() ?? 0;
|
|
62
|
+
},
|
|
63
|
+
getMetadata(key) {
|
|
64
|
+
return this.get(key)?.getMetadata() ?? null;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createTuiTimelineInstance({
|
|
70
|
+
key,
|
|
71
|
+
maxEvents = DEFAULT_MAX_TIMELINE_EVENTS,
|
|
72
|
+
persistDebounceMs = DEFAULT_TIMELINE_PERSIST_DEBOUNCE_MS,
|
|
73
|
+
onPersist = null,
|
|
74
|
+
} = {}) {
|
|
75
|
+
let events = [];
|
|
76
|
+
const projection = createTuiTimelineProjection();
|
|
77
|
+
let hydrated = false;
|
|
78
|
+
let dirty = false;
|
|
79
|
+
let lastAccessedAt = Date.now();
|
|
80
|
+
let lastUpdatedAt = null;
|
|
81
|
+
let lastPersistedAt = null;
|
|
82
|
+
let estimatedBytes = 0;
|
|
83
|
+
let persistTimer = null;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
apply(method, args, { at = Date.now(), persist = true } = {}) {
|
|
88
|
+
touch();
|
|
89
|
+
const event = { method, args, at };
|
|
90
|
+
events.push(event);
|
|
91
|
+
projection.apply(event);
|
|
92
|
+
trimToBudget();
|
|
93
|
+
dirty = true;
|
|
94
|
+
lastUpdatedAt = at;
|
|
95
|
+
if (persist) schedulePersist("debounce");
|
|
96
|
+
return event;
|
|
97
|
+
},
|
|
98
|
+
hydrateIfEmpty(nextEvents) {
|
|
99
|
+
touch();
|
|
100
|
+
if (events.length > 0) return false;
|
|
101
|
+
replaceEvents(nextEvents, { markHydrated: true });
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
replaceEvents(nextEvents) {
|
|
105
|
+
touch();
|
|
106
|
+
replaceEvents(nextEvents, { markHydrated: false });
|
|
107
|
+
},
|
|
108
|
+
clear({ flush = true } = {}) {
|
|
109
|
+
touch();
|
|
110
|
+
events = [];
|
|
111
|
+
projection.clear();
|
|
112
|
+
dirty = true;
|
|
113
|
+
lastUpdatedAt = Date.now();
|
|
114
|
+
estimatedBytes = 0;
|
|
115
|
+
if (flush) this.flushPersist("clear");
|
|
116
|
+
else schedulePersist("debounce");
|
|
117
|
+
},
|
|
118
|
+
replayTo(ui) {
|
|
119
|
+
touch();
|
|
120
|
+
if (typeof ui.restoreTimelineBlocks === "function") {
|
|
121
|
+
const blocks = projection.getBlocks();
|
|
122
|
+
ui.restoreTimelineBlocks(blocks);
|
|
123
|
+
return blocks.length;
|
|
124
|
+
}
|
|
125
|
+
for (const event of events) applyRenderEvent(ui, event);
|
|
126
|
+
return events.length;
|
|
127
|
+
},
|
|
128
|
+
flushPersist(reason = "manual") {
|
|
129
|
+
clearPersistTimer();
|
|
130
|
+
if (!dirty || typeof onPersist !== "function") return false;
|
|
131
|
+
onPersist({ key, events: this.getEvents(), reason, timeline: this.getMetadata() });
|
|
132
|
+
dirty = false;
|
|
133
|
+
lastPersistedAt = Date.now();
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
getEvents() {
|
|
137
|
+
touch();
|
|
138
|
+
return cloneEvents(events);
|
|
139
|
+
},
|
|
140
|
+
getBlocks() {
|
|
141
|
+
touch();
|
|
142
|
+
return projection.getBlocks();
|
|
143
|
+
},
|
|
144
|
+
getEventCount() {
|
|
145
|
+
return events.length;
|
|
146
|
+
},
|
|
147
|
+
markPersisted() {
|
|
148
|
+
clearPersistTimer();
|
|
149
|
+
dirty = false;
|
|
150
|
+
lastPersistedAt = Date.now();
|
|
151
|
+
},
|
|
152
|
+
getMetadata() {
|
|
153
|
+
return buildMetadata();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function schedulePersist(reason) {
|
|
158
|
+
if (typeof onPersist !== "function") return;
|
|
159
|
+
clearPersistTimer();
|
|
160
|
+
persistTimer = setTimeout(() => {
|
|
161
|
+
persistTimer = null;
|
|
162
|
+
if (!dirty) return;
|
|
163
|
+
onPersist({ key, events: cloneEvents(events), reason, timeline: buildMetadata() });
|
|
164
|
+
dirty = false;
|
|
165
|
+
lastPersistedAt = Date.now();
|
|
166
|
+
}, Math.max(0, persistDebounceMs));
|
|
167
|
+
persistTimer.unref?.();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clearPersistTimer() {
|
|
171
|
+
if (!persistTimer) return;
|
|
172
|
+
clearTimeout(persistTimer);
|
|
173
|
+
persistTimer = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function replaceEvents(nextEvents, { markHydrated }) {
|
|
177
|
+
clearPersistTimer();
|
|
178
|
+
events = normalizeTimelineEvents(nextEvents);
|
|
179
|
+
trimToBudget();
|
|
180
|
+
rebuildProjection();
|
|
181
|
+
hydrated = markHydrated;
|
|
182
|
+
dirty = false;
|
|
183
|
+
lastUpdatedAt = events.at(-1)?.at ?? null;
|
|
184
|
+
lastPersistedAt = null;
|
|
185
|
+
updateEstimatedBytes();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function trimToBudget() {
|
|
189
|
+
if (events.length > maxEvents) {
|
|
190
|
+
events.splice(0, events.length - maxEvents);
|
|
191
|
+
rebuildProjection();
|
|
192
|
+
}
|
|
193
|
+
updateEstimatedBytes();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function rebuildProjection() {
|
|
197
|
+
projection.rebuild(events);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function updateEstimatedBytes() {
|
|
201
|
+
estimatedBytes = estimateJsonBytes(events) + estimateJsonBytes(projection.getBlocks());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function touch() {
|
|
205
|
+
lastAccessedAt = Date.now();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildMetadata() {
|
|
209
|
+
return {
|
|
210
|
+
key,
|
|
211
|
+
eventCount: events.length,
|
|
212
|
+
maxEvents,
|
|
213
|
+
hydrated,
|
|
214
|
+
dirty,
|
|
215
|
+
lastAccessedAt,
|
|
216
|
+
lastUpdatedAt,
|
|
217
|
+
lastPersistedAt,
|
|
218
|
+
estimatedBytes,
|
|
219
|
+
persistScheduled: Boolean(persistTimer),
|
|
220
|
+
...projection.getMetadata(),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function normalizeTimelineEvents(events) {
|
|
226
|
+
if (!Array.isArray(events)) return [];
|
|
227
|
+
return events
|
|
228
|
+
.filter((event) => typeof event?.method === "string" && Array.isArray(event.args))
|
|
229
|
+
.map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function applyRenderEvent(ui, { method, args }) {
|
|
233
|
+
const value = ui[method];
|
|
234
|
+
if (typeof value === "function") value.apply(ui, args);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cloneEvents(items) {
|
|
238
|
+
return items.map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function estimateJsonBytes(value) {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.stringify(value).length;
|
|
244
|
+
} catch {
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -38,6 +38,26 @@ export function upsertProviderProfile({ path = globalConfigJsonPath(), id, type,
|
|
|
38
38
|
return config;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function removeProviderProfile({ path = globalConfigJsonPath(), id }) {
|
|
42
|
+
const config = readConfigJson(path);
|
|
43
|
+
const providers = config.providers && typeof config.providers === "object" && !Array.isArray(config.providers)
|
|
44
|
+
? config.providers
|
|
45
|
+
: {};
|
|
46
|
+
const hadProviderProfile = Object.prototype.hasOwnProperty.call(providers, id);
|
|
47
|
+
const wasSelectedProvider = config.provider === id;
|
|
48
|
+
if (hadProviderProfile) delete providers[id];
|
|
49
|
+
if (Object.keys(providers).length) config.providers = providers;
|
|
50
|
+
else delete config.providers;
|
|
51
|
+
if (wasSelectedProvider) {
|
|
52
|
+
delete config.provider;
|
|
53
|
+
delete config.model;
|
|
54
|
+
delete config.serviceTier;
|
|
55
|
+
}
|
|
56
|
+
if (!hadProviderProfile && !wasSelectedProvider) return false;
|
|
57
|
+
writeConfigJson(path, config);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
export function upsertSharedProviderProfile({ path = globalConfigJsonPath(), id, provider }) {
|
|
42
62
|
const config = readConfigJson(path);
|
|
43
63
|
const providers = config.providers && typeof config.providers === "object" && !Array.isArray(config.providers)
|
package/src/provider/command.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { runProviderConfigCommand } from "./config-command.mjs";
|
|
3
3
|
import { runProviderShareCommand } from "./share-command.mjs";
|
|
4
4
|
import { runProviderAcceptCommand } from "./accept-command.mjs";
|
|
5
|
+
import { runProviderRemoveCommand } from "./remove-command.mjs";
|
|
5
6
|
|
|
6
7
|
export async function runProviderCommand(args, { homeDir = homedir(), stderr = process.stderr } = {}) {
|
|
7
8
|
if (args.providerConfig) return await runProviderConfigCommand({ homeDir });
|
|
@@ -16,6 +17,9 @@ export async function runProviderCommand(args, { homeDir = homedir(), stderr = p
|
|
|
16
17
|
if (args.command.args[0] === "accept") {
|
|
17
18
|
return await runProviderAcceptCommand({ homeDir, token: args.command.args[1] });
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
if (args.command.args[0] === "remove" || args.command.args[0] === "uninstall") {
|
|
21
|
+
return await runProviderRemoveCommand({ homeDir, providerId: args.command.args[1] });
|
|
22
|
+
}
|
|
23
|
+
stderr.write("Usage: march provider --config | march provider share [id] | march provider accept <token> | march provider remove\n");
|
|
20
24
|
return 1;
|
|
21
25
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { globalConfigJsonPath, readConfigJson, removeProviderProfile } from "../config/config-json.mjs";
|
|
4
|
+
import { getMarchAuthPath } from "../auth/storage.mjs";
|
|
5
|
+
import { selectWithKeyboard } from "../cli/input/select-with-keyboard.mjs";
|
|
6
|
+
import { getProviderLabel } from "./presets.mjs";
|
|
7
|
+
|
|
8
|
+
export async function runProviderRemoveCommand({
|
|
9
|
+
homeDir,
|
|
10
|
+
providerId,
|
|
11
|
+
input = process.stdin,
|
|
12
|
+
output = process.stdout,
|
|
13
|
+
select = selectWithKeyboard,
|
|
14
|
+
confirm = confirmRemoval,
|
|
15
|
+
authStorage = AuthStorage.create(getMarchAuthPath(homeDir)),
|
|
16
|
+
} = {}) {
|
|
17
|
+
const removableProviders = listRemovableProviders({ homeDir, authStorage });
|
|
18
|
+
const selectedProviderId = providerId ?? await selectProviderToRemove({ removableProviders, input, output, select });
|
|
19
|
+
if (!selectedProviderId) {
|
|
20
|
+
if (!removableProviders.length) {
|
|
21
|
+
output.write("No configured providers to remove.\nRun `march provider --config` to add one.\n");
|
|
22
|
+
} else {
|
|
23
|
+
output.write("Provider removal cancelled.\n");
|
|
24
|
+
}
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const provider = removableProviders.find((item) => item.id === selectedProviderId) ?? {
|
|
29
|
+
id: selectedProviderId,
|
|
30
|
+
label: getProviderLabel(selectedProviderId),
|
|
31
|
+
sources: [],
|
|
32
|
+
};
|
|
33
|
+
const confirmed = await confirm({ input, output, provider });
|
|
34
|
+
if (!confirmed) {
|
|
35
|
+
output.write("Provider removal cancelled.\n");
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const configRemoved = removeProviderProfile({ path: globalConfigJsonPath(homeDir), id: selectedProviderId });
|
|
40
|
+
const credentialRemoved = removeProviderCredential({ authStorage, id: selectedProviderId });
|
|
41
|
+
if (!configRemoved && !credentialRemoved) {
|
|
42
|
+
output.write(`Provider not found: ${selectedProviderId}\n`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
output.write(`Removed provider: ${provider.label} (${selectedProviderId})\n`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function listRemovableProviders({ homeDir, authStorage }) {
|
|
51
|
+
const config = readConfigJson(globalConfigJsonPath(homeDir));
|
|
52
|
+
const providers = config.providers && typeof config.providers === "object" && !Array.isArray(config.providers)
|
|
53
|
+
? config.providers
|
|
54
|
+
: {};
|
|
55
|
+
const ids = new Set(Object.keys(providers));
|
|
56
|
+
for (const id of safeListAuthProviders(authStorage)) ids.add(id);
|
|
57
|
+
return [...ids].sort((a, b) => getProviderLabel(a).localeCompare(getProviderLabel(b))).map((id) => {
|
|
58
|
+
const sources = [];
|
|
59
|
+
if (Object.prototype.hasOwnProperty.call(providers, id)) sources.push("config");
|
|
60
|
+
if (safeHasAuthProvider(authStorage, id)) sources.push("credential");
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
label: getProviderLabel(id),
|
|
64
|
+
sources,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function selectProviderToRemove({ removableProviders, input, output, select }) {
|
|
70
|
+
if (!removableProviders.length) return null;
|
|
71
|
+
return await select({
|
|
72
|
+
input,
|
|
73
|
+
output,
|
|
74
|
+
message: "Select provider to remove",
|
|
75
|
+
items: removableProviders.map((provider) => ({
|
|
76
|
+
label: `${provider.label} (${provider.id})${formatSources(provider.sources)}`,
|
|
77
|
+
value: provider.id,
|
|
78
|
+
})),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function removeProviderCredential({ authStorage, id }) {
|
|
83
|
+
const existed = safeHasAuthProvider(authStorage, id);
|
|
84
|
+
if (typeof authStorage.remove === "function") authStorage.remove(id);
|
|
85
|
+
return existed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeListAuthProviders(authStorage) {
|
|
89
|
+
if (typeof authStorage.list !== "function") return [];
|
|
90
|
+
try {
|
|
91
|
+
const providers = authStorage.list();
|
|
92
|
+
return Array.isArray(providers) ? providers.filter((id) => typeof id === "string" && id) : [];
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function safeHasAuthProvider(authStorage, id) {
|
|
99
|
+
if (typeof authStorage.get === "function") {
|
|
100
|
+
try {
|
|
101
|
+
return authStorage.get(id) != null;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return safeListAuthProviders(authStorage).includes(id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatSources(sources) {
|
|
110
|
+
if (!sources.length) return "";
|
|
111
|
+
return ` — ${sources.join(" + ")}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function confirmRemoval({ input, output, provider }) {
|
|
115
|
+
const answer = String(await readLine({
|
|
116
|
+
input,
|
|
117
|
+
output,
|
|
118
|
+
prompt: `Remove provider "${provider.label}" (${provider.id})? This deletes local config and credentials. [y/N] `,
|
|
119
|
+
}) ?? "").trim().toLowerCase();
|
|
120
|
+
return answer === "y" || answer === "yes";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readLine({ input = process.stdin, output = process.stdout, prompt }) {
|
|
124
|
+
const rl = createInterface({ input, output });
|
|
125
|
+
return new Promise((resolve) => rl.question(prompt, (answer) => {
|
|
126
|
+
rl.close();
|
|
127
|
+
resolve(answer);
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, closeSync } from "node:fs";
|
|
2
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { getMarchSessionStateDir, normalizeSessionId } from "../state/march-session-state.mjs";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SESSION_CONTROLLER_LEASE_TTL_MS = 30_000;
|
|
7
|
+
export const DEFAULT_SESSION_CONTROLLER_HEARTBEAT_MS = 5_000;
|
|
8
|
+
|
|
9
|
+
export class SessionControllerLeaseConflictError extends Error {
|
|
10
|
+
constructor({ sessionId, owner }) {
|
|
11
|
+
super(formatControllerLeaseConflict({ sessionId, owner }));
|
|
12
|
+
this.name = "SessionControllerLeaseConflictError";
|
|
13
|
+
this.code = "SESSION_CONTROLLER_LEASE_CONFLICT";
|
|
14
|
+
this.sessionId = sessionId;
|
|
15
|
+
this.owner = owner;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createSessionControllerLeaseManager({
|
|
20
|
+
instanceId = randomUUID(),
|
|
21
|
+
pid = process.pid,
|
|
22
|
+
cwd = process.cwd(),
|
|
23
|
+
now = () => Date.now(),
|
|
24
|
+
ttlMs = DEFAULT_SESSION_CONTROLLER_LEASE_TTL_MS,
|
|
25
|
+
heartbeatMs = DEFAULT_SESSION_CONTROLLER_HEARTBEAT_MS,
|
|
26
|
+
} = {}) {
|
|
27
|
+
const ownerBase = { instanceId, pid, cwd: resolve(cwd) };
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
instanceId,
|
|
31
|
+
acquire(session, options = {}) {
|
|
32
|
+
const target = resolveControllerLeaseTarget(session);
|
|
33
|
+
const path = getSessionControllerLeasePath(target);
|
|
34
|
+
const lease = writeLease({ path, target, ownerBase, now, ttlMs, force: Boolean(options.force) });
|
|
35
|
+
const heartbeat = heartbeatMs > 0 ? setInterval(() => {
|
|
36
|
+
try { refreshLease({ path, lease, now, ttlMs }); } catch {}
|
|
37
|
+
}, heartbeatMs) : null;
|
|
38
|
+
heartbeat?.unref?.();
|
|
39
|
+
return {
|
|
40
|
+
...lease,
|
|
41
|
+
path,
|
|
42
|
+
target,
|
|
43
|
+
assertOwned() {
|
|
44
|
+
const current = readLease(path);
|
|
45
|
+
if (!current || current.owner?.instanceId !== lease.owner.instanceId || current.token !== lease.token || isExpired(current, now())) {
|
|
46
|
+
throw new SessionControllerLeaseConflictError({ sessionId: target.sessionId, owner: current?.owner ?? null });
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
release() {
|
|
50
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
51
|
+
releaseLease({ path, lease });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getSessionControllerLeasePath({ sessionId, sessionPath = null, projectMarchDir = null }) {
|
|
59
|
+
if (sessionPath) {
|
|
60
|
+
const identity = resolve(sessionPath);
|
|
61
|
+
const key = createHash("sha256").update(identity).digest("hex").slice(0, 32);
|
|
62
|
+
return join(dirname(identity), ".march-controller-leases", `${key}.json`);
|
|
63
|
+
}
|
|
64
|
+
if (!projectMarchDir || !sessionId) throw new Error("session controller lease requires a session path or project March dir plus session id");
|
|
65
|
+
return join(getMarchSessionStateDir(projectMarchDir, sessionId), "controller-lease.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveControllerLeaseTarget({ sessionId, sessionPath = null, projectMarchDir = null }) {
|
|
69
|
+
const id = normalizeSessionId(sessionId);
|
|
70
|
+
return {
|
|
71
|
+
sessionId: id,
|
|
72
|
+
sessionPath: sessionPath ? resolve(sessionPath) : null,
|
|
73
|
+
projectMarchDir: projectMarchDir ? resolve(projectMarchDir) : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeLease({ path, target, ownerBase, now, ttlMs, force }) {
|
|
78
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
79
|
+
const current = readLease(path);
|
|
80
|
+
if (current && !force && !isExpired(current, now()) && current.owner?.instanceId !== ownerBase.instanceId) {
|
|
81
|
+
throw new SessionControllerLeaseConflictError({ sessionId: target.sessionId, owner: current.owner });
|
|
82
|
+
}
|
|
83
|
+
const lease = createLease({ target, ownerBase, now, ttlMs });
|
|
84
|
+
if (force || current) {
|
|
85
|
+
writeFileSync(path, JSON.stringify(lease, null, 2), "utf8");
|
|
86
|
+
return lease;
|
|
87
|
+
}
|
|
88
|
+
let fd = null;
|
|
89
|
+
try {
|
|
90
|
+
fd = openSync(path, "wx");
|
|
91
|
+
writeFileSync(fd, JSON.stringify(lease, null, 2), "utf8");
|
|
92
|
+
return lease;
|
|
93
|
+
} catch {
|
|
94
|
+
const raced = readLease(path);
|
|
95
|
+
if (raced && !isExpired(raced, now())) throw new SessionControllerLeaseConflictError({ sessionId: target.sessionId, owner: raced.owner });
|
|
96
|
+
try { unlinkSync(path); } catch {}
|
|
97
|
+
writeFileSync(path, JSON.stringify(lease, null, 2), "utf8");
|
|
98
|
+
return lease;
|
|
99
|
+
} finally {
|
|
100
|
+
if (fd !== null) closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createLease({ target, ownerBase, now, ttlMs }) {
|
|
105
|
+
const acquiredAtMs = now();
|
|
106
|
+
return {
|
|
107
|
+
version: 1,
|
|
108
|
+
token: randomUUID(),
|
|
109
|
+
sessionId: target.sessionId,
|
|
110
|
+
sessionPath: target.sessionPath,
|
|
111
|
+
owner: ownerBase,
|
|
112
|
+
acquiredAt: new Date(acquiredAtMs).toISOString(),
|
|
113
|
+
heartbeatAt: new Date(acquiredAtMs).toISOString(),
|
|
114
|
+
expiresAt: new Date(acquiredAtMs + ttlMs).toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function refreshLease({ path, lease, now, ttlMs }) {
|
|
119
|
+
const current = readLease(path);
|
|
120
|
+
if (!current || current.token !== lease.token || current.owner?.instanceId !== lease.owner.instanceId) return;
|
|
121
|
+
const heartbeatAtMs = now();
|
|
122
|
+
writeFileSync(path, JSON.stringify({
|
|
123
|
+
...current,
|
|
124
|
+
heartbeatAt: new Date(heartbeatAtMs).toISOString(),
|
|
125
|
+
expiresAt: new Date(heartbeatAtMs + ttlMs).toISOString(),
|
|
126
|
+
}, null, 2), "utf8");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function releaseLease({ path, lease }) {
|
|
130
|
+
const current = readLease(path);
|
|
131
|
+
if (!current || current.token !== lease.token || current.owner?.instanceId !== lease.owner.instanceId) return;
|
|
132
|
+
try { unlinkSync(path); } catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readLease(path) {
|
|
136
|
+
if (!existsSync(path)) return null;
|
|
137
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isExpired(lease, nowMs) {
|
|
141
|
+
return Date.parse(lease.expiresAt ?? 0) <= nowMs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatControllerLeaseConflict({ sessionId, owner }) {
|
|
145
|
+
const parts = [`Session "${sessionId}" is already controlled by another March instance.`];
|
|
146
|
+
if (owner?.cwd) parts.push(`cwd: ${owner.cwd}`);
|
|
147
|
+
if (owner?.pid) parts.push(`pid: ${owner.pid}`);
|
|
148
|
+
return parts.join(" ");
|
|
149
|
+
}
|
|
@@ -45,7 +45,7 @@ export function saveMarchSessionState({ projectMarchDir, sessionId, engine, back
|
|
|
45
45
|
export function saveMarchSessionStateValue({ projectMarchDir, sessionId, state }) {
|
|
46
46
|
if (!sessionId) throw new Error("March session id is required");
|
|
47
47
|
const existing = loadMarchSessionState({ projectMarchDir, sessionId })?.state ?? null;
|
|
48
|
-
const nextState = normalizeMarchSessionStateForSave({ ...existing, ...state
|
|
48
|
+
const nextState = normalizeMarchSessionStateForSave({ ...existing, ...state });
|
|
49
49
|
validateMarchSessionState(nextState);
|
|
50
50
|
const dir = getMarchSessionStateDir(projectMarchDir, sessionId);
|
|
51
51
|
mkdirSync(dir, { recursive: true });
|
|
@@ -135,22 +135,12 @@ function validateMarchSessionState(state) {
|
|
|
135
135
|
function isValidMarchSessionState(state) {
|
|
136
136
|
return state?.version === MARCH_SESSION_STATE_VERSION
|
|
137
137
|
&& Boolean(state.cwd)
|
|
138
|
-
&& Array.isArray(state.turns)
|
|
139
|
-
&& Array.isArray(state.renderTimeline);
|
|
138
|
+
&& Array.isArray(state.turns);
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
function normalizeMarchSessionStateForSave(state) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
renderTimeline: normalizePersistedRenderTimeline(state.renderTimeline),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function normalizePersistedRenderTimeline(events) {
|
|
150
|
-
if (!Array.isArray(events)) return [];
|
|
151
|
-
return events
|
|
152
|
-
.filter((event) => typeof event?.method === "string" && Array.isArray(event.args))
|
|
153
|
-
.map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
|
|
142
|
+
const { renderTimeline: _renderTimeline, renderTimelineUpdatedAt: _renderTimelineUpdatedAt, ...coreState } = state ?? {};
|
|
143
|
+
return coreState;
|
|
154
144
|
}
|
|
155
145
|
|
|
156
146
|
function normalizeSessionRef(sessionRef) {
|