@hasna/snapshots 0.1.1 → 0.1.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/README.md +15 -6
- package/dist/agent/index.js +0 -0
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +39 -13
- package/dist/capture/index.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +55 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/mcp/index.js +15 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/restore.d.ts +1 -0
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +392 -27
- package/dist/restore.js.map +1 -1
- package/dist/runtime.d.ts +12 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +60 -2
- package/dist/runtime.js.map +1 -1
- package/dist/server/index.js +9 -1
- package/dist/server/index.js.map +1 -1
- package/dist/service.d.ts +1 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +59 -2
- package/dist/service.js.map +1 -1
- package/dist/storage.d.ts +3 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +61 -3
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +58 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/restore.js
CHANGED
|
@@ -3,8 +3,23 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { commandExists, nowIso, runTmux, sha256, stableJson, tmuxCommand } from "./util.js";
|
|
4
4
|
import { resolvePolicy } from "./policy.js";
|
|
5
5
|
export function createRestorePlan(snapshot, resources, policies = [], options = {}) {
|
|
6
|
+
const request = normalizeRestoreRequest(options);
|
|
7
|
+
const selection = selectResources(resources, request);
|
|
8
|
+
const selectedResourceIds = new Set(selection.resources.map((resource) => resource.id));
|
|
9
|
+
const strictExistingTmuxSessions = strictExistingTmuxSessionNames(selection.resources, request);
|
|
6
10
|
const operations = [];
|
|
7
|
-
|
|
11
|
+
const planWarnings = [...selection.warnings, ...tmuxPlanWarnings(selection.resources, request)];
|
|
12
|
+
for (const resource of selection.resources) {
|
|
13
|
+
const missingParent = missingSelectedParent(resource, resources, selectedResourceIds);
|
|
14
|
+
if (missingParent) {
|
|
15
|
+
operations.push(operation(resource, "dependency.missing", `Resource requires parent ${missingParent}. Re-run with --with-dependencies to include it.`, "blocked", "Partial restore cannot safely apply a child resource without its captured parent.", undefined, {
|
|
16
|
+
dependsOn: [missingParent],
|
|
17
|
+
warnings: ["Dependency closure is incomplete."],
|
|
18
|
+
confidence: "impossible",
|
|
19
|
+
risk: "medium"
|
|
20
|
+
}));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
8
23
|
const policy = resolvePolicy(resource, policies);
|
|
9
24
|
if (policy.mode === "ignore") {
|
|
10
25
|
operations.push(operation(resource, "ignored", "Ignored by restore policy.", "skipped", policy.reason));
|
|
@@ -18,14 +33,25 @@ export function createRestorePlan(snapshot, resources, policies = [], options =
|
|
|
18
33
|
operations.push(planProject(resource));
|
|
19
34
|
}
|
|
20
35
|
else if (resource.kind === "tmux-session") {
|
|
21
|
-
operations.push(planTmuxSession(resource, resources));
|
|
36
|
+
operations.push(planTmuxSession(resource, resources, request));
|
|
37
|
+
operations.push(...planTmuxSessionState(resource, resources));
|
|
22
38
|
}
|
|
23
39
|
else if (resource.kind === "tmux-window") {
|
|
24
|
-
|
|
40
|
+
const blockedSession = tmuxSessionForResource(resource);
|
|
41
|
+
if (blockedSession && strictExistingTmuxSessions.has(blockedSession)) {
|
|
42
|
+
operations.push(blockedExistingTmuxSubtree(resource, blockedSession));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
operations.push(planTmuxWindow(resource, request));
|
|
25
46
|
operations.push(...planTmuxWindowState(resource));
|
|
26
47
|
}
|
|
27
48
|
else if (resource.kind === "tmux-pane") {
|
|
28
|
-
|
|
49
|
+
const blockedSession = tmuxSessionForResource(resource);
|
|
50
|
+
if (blockedSession && strictExistingTmuxSessions.has(blockedSession)) {
|
|
51
|
+
operations.push(blockedExistingTmuxSubtree(resource, blockedSession));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
operations.push(planTmuxPane(resource, resources, request));
|
|
29
55
|
operations.push(...planTmuxPaneState(resource));
|
|
30
56
|
}
|
|
31
57
|
else if (resource.kind === "process") {
|
|
@@ -38,14 +64,25 @@ export function createRestorePlan(snapshot, resources, policies = [], options =
|
|
|
38
64
|
operations.push(operation(resource, "unsupported", "No restore adapter for this resource kind.", "skipped"));
|
|
39
65
|
}
|
|
40
66
|
}
|
|
41
|
-
const
|
|
42
|
-
id: `plan_${snapshot.id}
|
|
67
|
+
const basePlan = {
|
|
68
|
+
id: `plan_${snapshot.id}_pending`,
|
|
43
69
|
snapshotId: snapshot.id,
|
|
44
70
|
createdAt: nowIso(),
|
|
45
71
|
apply: Boolean(options.apply),
|
|
72
|
+
request,
|
|
73
|
+
matchedSelectors: selection.matchedSelectors,
|
|
74
|
+
unmatchedSelectors: selection.unmatchedSelectors,
|
|
75
|
+
autoAddedDependencies: selection.autoAddedDependencies,
|
|
76
|
+
warnings: planWarnings,
|
|
77
|
+
autopilot: assessAutopilot(operations),
|
|
46
78
|
operations: sortOperations(operations),
|
|
47
79
|
summary: summarizeOperations(operations)
|
|
48
80
|
};
|
|
81
|
+
basePlan.planHash = hashRestorePlan(basePlan);
|
|
82
|
+
const plan = {
|
|
83
|
+
...basePlan,
|
|
84
|
+
id: `plan_${snapshot.id}_${basePlan.planHash.slice(0, 12)}`
|
|
85
|
+
};
|
|
49
86
|
if (options.apply) {
|
|
50
87
|
return executeRestorePlan(plan, options);
|
|
51
88
|
}
|
|
@@ -87,7 +124,7 @@ function planProject(resource) {
|
|
|
87
124
|
path
|
|
88
125
|
]);
|
|
89
126
|
}
|
|
90
|
-
function planTmuxSession(resource, resources) {
|
|
127
|
+
function planTmuxSession(resource, resources, request) {
|
|
91
128
|
const name = resource.name;
|
|
92
129
|
if (!/^[A-Za-z0-9_.:-]+$/.test(name)) {
|
|
93
130
|
return operation(resource, "tmux.create-session", `Unsafe tmux session name: ${name}`, "blocked");
|
|
@@ -112,32 +149,55 @@ function planTmuxSession(resource, resources) {
|
|
|
112
149
|
command.push("-n", firstWindow.attributes.name);
|
|
113
150
|
}
|
|
114
151
|
const startCommand = typeof firstWindow?.attributes.start_command === "string" ? firstWindow.attributes.start_command : "";
|
|
115
|
-
if (firstWindow
|
|
152
|
+
if (shouldReplayTmuxCommand(firstWindow, request, startCommand)) {
|
|
116
153
|
command.push(startCommand);
|
|
117
154
|
}
|
|
118
155
|
return operation(resource, "tmux.create-session", `Create detached tmux session: ${name}`, "planned", undefined, [
|
|
119
156
|
...command
|
|
120
|
-
]);
|
|
157
|
+
], tmuxCreateExtras(firstWindow ?? resource, request, startCommand));
|
|
158
|
+
}
|
|
159
|
+
function planTmuxSessionState(resource, resources) {
|
|
160
|
+
const name = resource.name;
|
|
161
|
+
if (!commandExists("tmux"))
|
|
162
|
+
return [];
|
|
163
|
+
if (tmuxSessionExists(name))
|
|
164
|
+
return [];
|
|
165
|
+
const firstWindow = resources
|
|
166
|
+
.filter((candidate) => candidate.kind === "tmux-window" && candidate.parentId === resource.id)
|
|
167
|
+
.sort((a, b) => Number(a.attributes.index ?? 0) - Number(b.attributes.index ?? 0))[0];
|
|
168
|
+
const windowIndex = Number(firstWindow?.attributes.index);
|
|
169
|
+
if (!Number.isFinite(windowIndex))
|
|
170
|
+
return [];
|
|
171
|
+
return [
|
|
172
|
+
operation(firstWindow ?? resource, "tmux.move-window", `Restore first tmux window index: ${name}:${windowIndex}`, "planned", undefined, tmuxCommand(["move-window", "-s", `${name}:`, "-t", `${name}:${windowIndex}`]), {
|
|
173
|
+
confidence: "best-effort",
|
|
174
|
+
warnings: ["Moves the implicit first tmux window created by new-session to the captured index when tmux base-index differs."],
|
|
175
|
+
risk: "low",
|
|
176
|
+
effects: ["restore tmux first-window index"]
|
|
177
|
+
})
|
|
178
|
+
];
|
|
121
179
|
}
|
|
122
|
-
function planTmuxWindow(resource) {
|
|
180
|
+
function planTmuxWindow(resource, request) {
|
|
123
181
|
const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
124
182
|
const name = typeof resource.attributes.name === "string" ? resource.attributes.name : resource.name.split(":").slice(2).join(":");
|
|
183
|
+
const windowIndex = Number(resource.attributes.index);
|
|
125
184
|
if (!session || !name)
|
|
126
185
|
return operation(resource, "tmux.create-window", "Window is missing session/name metadata.", "blocked");
|
|
127
186
|
if (!commandExists("tmux"))
|
|
128
187
|
return operation(resource, "tmux.create-window", "tmux is not installed or not on PATH.", "blocked");
|
|
129
|
-
if (tmuxWindowExists(session, name)) {
|
|
130
|
-
return operation(resource, "tmux.window-exists", `tmux window already exists: ${session}:${name}`, "noop");
|
|
188
|
+
if (tmuxWindowExists(session, name, Number.isFinite(windowIndex) ? windowIndex : undefined)) {
|
|
189
|
+
return operation(resource, "tmux.window-exists", `tmux window already exists: ${session}:${Number.isFinite(windowIndex) ? windowIndex : name}`, "noop");
|
|
131
190
|
}
|
|
132
191
|
const cwd = typeof resource.attributes.current_path === "string" ? resource.attributes.current_path : process.cwd();
|
|
133
|
-
const
|
|
192
|
+
const target = Number.isFinite(windowIndex) ? `${session}:${windowIndex}` : session;
|
|
193
|
+
const command = tmuxCommand(["new-window", "-d", "-t", target, "-n", name, "-c", cwd]);
|
|
134
194
|
const startCommand = typeof resource.attributes.start_command === "string" ? resource.attributes.start_command : "";
|
|
135
|
-
if (resource
|
|
195
|
+
if (shouldReplayTmuxCommand(resource, request, startCommand)) {
|
|
136
196
|
command.push(startCommand);
|
|
137
197
|
}
|
|
138
|
-
return operation(resource, "tmux.create-window", `Create tmux window: ${session}:${name}`, "planned", undefined, command);
|
|
198
|
+
return operation(resource, "tmux.create-window", `Create tmux window: ${session}:${name}`, "planned", undefined, command, tmuxCreateExtras(resource, request, startCommand));
|
|
139
199
|
}
|
|
140
|
-
function planTmuxPane(resource, resources) {
|
|
200
|
+
function planTmuxPane(resource, resources, request) {
|
|
141
201
|
const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
142
202
|
const windowIndex = Number(resource.attributes.window_index);
|
|
143
203
|
const paneIndex = Number(resource.attributes.pane_index);
|
|
@@ -155,10 +215,10 @@ function planTmuxPane(resource, resources) {
|
|
|
155
215
|
const cwd = typeof resource.attributes.current_path === "string" ? resource.attributes.current_path : process.cwd();
|
|
156
216
|
const command = tmuxCommand(["split-window", "-d", "-t", `${session}:${windowIndex}`, "-c", cwd]);
|
|
157
217
|
const startCommand = typeof resource.attributes.start_command === "string" ? resource.attributes.start_command : "";
|
|
158
|
-
if (resource
|
|
218
|
+
if (shouldReplayTmuxCommand(resource, request, startCommand)) {
|
|
159
219
|
command.push(startCommand);
|
|
160
220
|
}
|
|
161
|
-
return operation(resource, "tmux.create-pane", `Create tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, command);
|
|
221
|
+
return operation(resource, "tmux.create-pane", `Create tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, command, tmuxCreateExtras(resource, request, startCommand));
|
|
162
222
|
}
|
|
163
223
|
function planTmuxWindowState(resource) {
|
|
164
224
|
const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
@@ -168,10 +228,16 @@ function planTmuxWindowState(resource) {
|
|
|
168
228
|
const operations = [];
|
|
169
229
|
const layout = typeof resource.attributes.layout === "string" ? resource.attributes.layout : undefined;
|
|
170
230
|
if (layout && Number(resource.attributes.pane_count ?? 0) > 1) {
|
|
171
|
-
operations.push(operation(resource, "tmux.select-layout", `Restore tmux layout: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-layout", "-t", `${session}:${windowIndex}`, layout])
|
|
231
|
+
operations.push(operation(resource, "tmux.select-layout", `Restore tmux layout: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-layout", "-t", `${session}:${windowIndex}`, layout]), {
|
|
232
|
+
confidence: "best-effort",
|
|
233
|
+
warnings: ["tmux layout restore is best-effort and does not restore shell/process state, scrollback, marks, or client size."]
|
|
234
|
+
}));
|
|
172
235
|
}
|
|
173
236
|
if (resource.attributes.active === true) {
|
|
174
|
-
operations.push(operation(resource, "tmux.select-window", `Restore active tmux window: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-window", "-t", `${session}:${windowIndex}`])
|
|
237
|
+
operations.push(operation(resource, "tmux.select-window", `Restore active tmux window: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-window", "-t", `${session}:${windowIndex}`]), {
|
|
238
|
+
confidence: "best-effort",
|
|
239
|
+
warnings: ["tmux active-window selection may affect the current live client when merging into an existing session."]
|
|
240
|
+
}));
|
|
175
241
|
}
|
|
176
242
|
return operations;
|
|
177
243
|
}
|
|
@@ -184,7 +250,10 @@ function planTmuxPaneState(resource) {
|
|
|
184
250
|
if (resource.attributes.active !== true)
|
|
185
251
|
return [];
|
|
186
252
|
return [
|
|
187
|
-
operation(resource, "tmux.select-pane", `Restore active tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, tmuxCommand(["select-pane", "-t", `${session}:${windowIndex}.${paneIndex}`])
|
|
253
|
+
operation(resource, "tmux.select-pane", `Restore active tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, tmuxCommand(["select-pane", "-t", `${session}:${windowIndex}.${paneIndex}`]), {
|
|
254
|
+
confidence: "best-effort",
|
|
255
|
+
warnings: ["tmux active-pane selection may affect the current live client when merging into an existing session."]
|
|
256
|
+
})
|
|
188
257
|
];
|
|
189
258
|
}
|
|
190
259
|
function planProcess(resource) {
|
|
@@ -275,6 +344,21 @@ function executeOperation(op) {
|
|
|
275
344
|
return { ...op, status: "applied" };
|
|
276
345
|
return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
|
|
277
346
|
}
|
|
347
|
+
if (op.kind === "tmux.move-window" && op.command) {
|
|
348
|
+
const session = String(op.resource?.attributes.session ?? "");
|
|
349
|
+
const windowIndex = Number(op.resource?.attributes.index);
|
|
350
|
+
if (session && Number.isFinite(windowIndex)) {
|
|
351
|
+
const current = runTmux(["display-message", "-p", "-t", `${session}:`, "#{window_index}"], 2_000);
|
|
352
|
+
if (current.status === 0 && Number(current.stdout.trim()) === windowIndex) {
|
|
353
|
+
return { ...op, status: "noop", reason: "First window already has the captured index." };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const [command, ...args] = op.command;
|
|
357
|
+
const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
|
|
358
|
+
if (result.status === 0)
|
|
359
|
+
return { ...op, status: "applied" };
|
|
360
|
+
return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
|
|
361
|
+
}
|
|
278
362
|
if ((op.kind === "tmux.select-layout" || op.kind === "tmux.select-pane" || op.kind === "tmux.select-window") && op.command) {
|
|
279
363
|
const [command, ...args] = op.command;
|
|
280
364
|
const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
|
|
@@ -297,7 +381,7 @@ function executeOperation(op) {
|
|
|
297
381
|
}
|
|
298
382
|
return { ...op, status: "blocked", reason: "No executor for operation kind." };
|
|
299
383
|
}
|
|
300
|
-
function operation(resource, kind, summary, status, reason, command) {
|
|
384
|
+
function operation(resource, kind, summary, status, reason, command, extra = {}) {
|
|
301
385
|
return {
|
|
302
386
|
id: `${kind}:${resource.id}`,
|
|
303
387
|
kind,
|
|
@@ -307,9 +391,274 @@ function operation(resource, kind, summary, status, reason, command) {
|
|
|
307
391
|
status,
|
|
308
392
|
reason,
|
|
309
393
|
command,
|
|
310
|
-
resource
|
|
394
|
+
resource,
|
|
395
|
+
...extra
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function normalizeRestoreRequest(options) {
|
|
399
|
+
const request = {
|
|
400
|
+
dependencyMode: options.dependencyMode ?? "none",
|
|
401
|
+
targetMode: options.targetMode ?? "strict",
|
|
402
|
+
tmuxMode: options.tmuxMode ?? "layout-only"
|
|
403
|
+
};
|
|
404
|
+
if (options.include?.length)
|
|
405
|
+
request.include = uniqueStrings(options.include);
|
|
406
|
+
if (options.exclude?.length)
|
|
407
|
+
request.exclude = uniqueStrings(options.exclude);
|
|
408
|
+
if (options.applyPlanId)
|
|
409
|
+
request.applyPlanId = options.applyPlanId;
|
|
410
|
+
if (options.planHash)
|
|
411
|
+
request.planHash = options.planHash;
|
|
412
|
+
return request;
|
|
413
|
+
}
|
|
414
|
+
function selectResources(resources, request) {
|
|
415
|
+
const include = request.include ?? [];
|
|
416
|
+
const exclude = request.exclude ?? [];
|
|
417
|
+
const matchedSelectors = [];
|
|
418
|
+
const unmatchedSelectors = [];
|
|
419
|
+
const warnings = [];
|
|
420
|
+
const autoAddedDependencies = [];
|
|
421
|
+
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
422
|
+
const childrenByParent = new Map();
|
|
423
|
+
for (const resource of resources) {
|
|
424
|
+
if (!resource.parentId)
|
|
425
|
+
continue;
|
|
426
|
+
const children = childrenByParent.get(resource.parentId) ?? [];
|
|
427
|
+
children.push(resource);
|
|
428
|
+
childrenByParent.set(resource.parentId, children);
|
|
429
|
+
}
|
|
430
|
+
const selectedIds = new Set();
|
|
431
|
+
const includeSelectors = include.length ? include : ["*"];
|
|
432
|
+
for (const selector of includeSelectors) {
|
|
433
|
+
const matches = matchResources(resources, selector).map((resource) => resource.id);
|
|
434
|
+
matchedSelectors.push({ selector, matchedResourceIds: matches });
|
|
435
|
+
if (!matches.length && selector !== "*")
|
|
436
|
+
unmatchedSelectors.push(selector);
|
|
437
|
+
for (const id of matches)
|
|
438
|
+
selectedIds.add(id);
|
|
439
|
+
}
|
|
440
|
+
if ((request.dependencyMode === "parents" || request.dependencyMode === "full") && include.length) {
|
|
441
|
+
for (const id of [...selectedIds])
|
|
442
|
+
addParentDependencies(id, id, byId, selectedIds, autoAddedDependencies);
|
|
443
|
+
}
|
|
444
|
+
if (request.dependencyMode === "full" && include.length) {
|
|
445
|
+
for (const id of [...selectedIds])
|
|
446
|
+
addChildDependencies(id, id, childrenByParent, selectedIds, autoAddedDependencies);
|
|
447
|
+
}
|
|
448
|
+
for (const selector of exclude) {
|
|
449
|
+
const matches = matchResources(resources, selector).map((resource) => resource.id);
|
|
450
|
+
matchedSelectors.push({ selector: `!${selector}`, matchedResourceIds: matches });
|
|
451
|
+
if (!matches.length)
|
|
452
|
+
unmatchedSelectors.push(`!${selector}`);
|
|
453
|
+
for (const id of matches)
|
|
454
|
+
selectedIds.delete(id);
|
|
455
|
+
}
|
|
456
|
+
if (include.length && request.dependencyMode === "none") {
|
|
457
|
+
warnings.push("Partial restore requested without dependency closure; child resources with omitted parents will be blocked.");
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
resources: resources.filter((resource) => selectedIds.has(resource.id)),
|
|
461
|
+
matchedSelectors,
|
|
462
|
+
unmatchedSelectors,
|
|
463
|
+
autoAddedDependencies: autoAddedDependencies.filter((entry) => selectedIds.has(entry.resourceId)),
|
|
464
|
+
warnings
|
|
311
465
|
};
|
|
312
466
|
}
|
|
467
|
+
function matchResources(resources, selector) {
|
|
468
|
+
const trimmed = selector.trim();
|
|
469
|
+
if (!trimmed || trimmed === "*")
|
|
470
|
+
return resources;
|
|
471
|
+
const [prefix, ...rest] = trimmed.split(":");
|
|
472
|
+
const value = rest.join(":");
|
|
473
|
+
if (!value)
|
|
474
|
+
return resources.filter((resource) => resource.id === trimmed);
|
|
475
|
+
if (prefix === "id")
|
|
476
|
+
return resources.filter((resource) => resource.id === value);
|
|
477
|
+
if (prefix === "kind")
|
|
478
|
+
return resources.filter((resource) => resource.kind === value);
|
|
479
|
+
if (prefix === "source")
|
|
480
|
+
return resources.filter((resource) => resource.source === value);
|
|
481
|
+
if (prefix === "parent")
|
|
482
|
+
return resources.filter((resource) => resource.parentId === value);
|
|
483
|
+
if (prefix === "name") {
|
|
484
|
+
const normalized = value.toLowerCase();
|
|
485
|
+
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized));
|
|
486
|
+
}
|
|
487
|
+
if (prefix === "path") {
|
|
488
|
+
return resources.filter((resource) => resourcePaths(resource).some((path) => path === value || path.startsWith(`${value}/`)));
|
|
489
|
+
}
|
|
490
|
+
return resources.filter((resource) => resource.id === trimmed);
|
|
491
|
+
}
|
|
492
|
+
function resourcePaths(resource) {
|
|
493
|
+
return ["path", "current_path", "app_path"]
|
|
494
|
+
.map((key) => resource.attributes[key])
|
|
495
|
+
.filter((value) => typeof value === "string");
|
|
496
|
+
}
|
|
497
|
+
function addParentDependencies(resourceId, requiredBy, byId, selectedIds, added) {
|
|
498
|
+
const resource = byId.get(resourceId);
|
|
499
|
+
if (!resource?.parentId)
|
|
500
|
+
return;
|
|
501
|
+
if (!selectedIds.has(resource.parentId)) {
|
|
502
|
+
selectedIds.add(resource.parentId);
|
|
503
|
+
added.push({ resourceId: resource.parentId, requiredBy, reason: "parent dependency" });
|
|
504
|
+
}
|
|
505
|
+
addParentDependencies(resource.parentId, requiredBy, byId, selectedIds, added);
|
|
506
|
+
}
|
|
507
|
+
function addChildDependencies(resourceId, requiredBy, childrenByParent, selectedIds, added) {
|
|
508
|
+
for (const child of childrenByParent.get(resourceId) ?? []) {
|
|
509
|
+
if (!selectedIds.has(child.id)) {
|
|
510
|
+
selectedIds.add(child.id);
|
|
511
|
+
added.push({ resourceId: child.id, requiredBy, reason: "child dependency" });
|
|
512
|
+
}
|
|
513
|
+
addChildDependencies(child.id, requiredBy, childrenByParent, selectedIds, added);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function missingSelectedParent(resource, allResources, selectedResourceIds) {
|
|
517
|
+
if (!resource.parentId)
|
|
518
|
+
return undefined;
|
|
519
|
+
if (selectedResourceIds.has(resource.parentId))
|
|
520
|
+
return undefined;
|
|
521
|
+
return allResources.some((candidate) => candidate.id === resource.parentId) ? resource.parentId : undefined;
|
|
522
|
+
}
|
|
523
|
+
function strictExistingTmuxSessionNames(resources, request) {
|
|
524
|
+
if (request.targetMode === "merge-existing")
|
|
525
|
+
return new Set();
|
|
526
|
+
if (!commandExists("tmux"))
|
|
527
|
+
return new Set();
|
|
528
|
+
const names = new Set();
|
|
529
|
+
for (const resource of resources) {
|
|
530
|
+
if (resource.kind !== "tmux-session")
|
|
531
|
+
continue;
|
|
532
|
+
if (tmuxSessionExists(resource.name))
|
|
533
|
+
names.add(resource.name);
|
|
534
|
+
}
|
|
535
|
+
return names;
|
|
536
|
+
}
|
|
537
|
+
function shouldReplayTmuxCommand(resource, request, startCommand) {
|
|
538
|
+
return request.tmuxMode === "resume-marked" && resource?.attributes.restartable === true && Boolean(startCommand);
|
|
539
|
+
}
|
|
540
|
+
function tmuxCreateExtras(resource, request, startCommand) {
|
|
541
|
+
const warnings = [
|
|
542
|
+
"tmux restore recreates layout/cwd best-effort; it cannot restore shell internals, scrollback, process memory, or client attachment."
|
|
543
|
+
];
|
|
544
|
+
if (startCommand && resource.attributes.restartable === true && request.tmuxMode !== "resume-marked") {
|
|
545
|
+
warnings.push("Captured restartable command was not replayed because tmux mode is layout-only.");
|
|
546
|
+
}
|
|
547
|
+
if (startCommand && resource.attributes.restartable !== true) {
|
|
548
|
+
warnings.push("Captured command is forensic-only because it lacks a restartable marker.");
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
warnings,
|
|
552
|
+
confidence: "best-effort",
|
|
553
|
+
risk: shouldReplayTmuxCommand(resource, request, startCommand) ? "high" : "low",
|
|
554
|
+
effects: shouldReplayTmuxCommand(resource, request, startCommand)
|
|
555
|
+
? ["create tmux structure", "replay restartable command"]
|
|
556
|
+
: ["create tmux structure"]
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function tmuxPlanWarnings(resources, request) {
|
|
560
|
+
const warnings = [];
|
|
561
|
+
if (resources.some((resource) => resource.kind === "tmux-session" && resource.attributes.attached === true)) {
|
|
562
|
+
warnings.push("tmux client attachment is captured for context but restore creates detached sessions.");
|
|
563
|
+
}
|
|
564
|
+
if (request.tmuxMode === "layout-only" && resources.some((resource) => resource.source === "tmux" && typeof resource.attributes.start_command === "string" && resource.attributes.start_command)) {
|
|
565
|
+
warnings.push("tmux restore mode is layout-only; captured start commands are preserved as forensic data but not replayed.");
|
|
566
|
+
}
|
|
567
|
+
return warnings;
|
|
568
|
+
}
|
|
569
|
+
function tmuxSessionExists(name) {
|
|
570
|
+
return runTmux(["has-session", "-t", name], 2_000).status === 0;
|
|
571
|
+
}
|
|
572
|
+
function tmuxSessionForResource(resource) {
|
|
573
|
+
if (resource.kind === "tmux-session")
|
|
574
|
+
return resource.name;
|
|
575
|
+
return typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
576
|
+
}
|
|
577
|
+
function blockedExistingTmuxSubtree(resource, session) {
|
|
578
|
+
return operation(resource, "tmux.blocked-existing-session", `Blocked restore into existing tmux session: ${session}`, "blocked", "Existing tmux sessions are not merged by default. Re-run with --merge-existing to opt into live-session mutation.", undefined, {
|
|
579
|
+
preconditions: [`tmux session must be absent or --merge-existing must be set: ${session}`],
|
|
580
|
+
warnings: ["Default strict restore avoids mutating live tmux sessions."],
|
|
581
|
+
risk: "high",
|
|
582
|
+
confidence: "impossible"
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
function assessAutopilot(operations) {
|
|
586
|
+
const allowedOperationIds = [];
|
|
587
|
+
const approvalRequiredOperationIds = [];
|
|
588
|
+
const forbiddenOperationIds = [];
|
|
589
|
+
const reasons = [];
|
|
590
|
+
for (const op of operations) {
|
|
591
|
+
if (op.status === "blocked" || op.status === "failed") {
|
|
592
|
+
forbiddenOperationIds.push(op.id);
|
|
593
|
+
reasons.push(`${op.id}: ${op.status} operation prevents autopilot apply.`);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (op.status !== "planned")
|
|
597
|
+
continue;
|
|
598
|
+
if (op.kind === "project.mkdir") {
|
|
599
|
+
allowedOperationIds.push(op.id);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (op.command?.[0] === "sh" && op.command?.[1] === "-lc") {
|
|
603
|
+
forbiddenOperationIds.push(op.id);
|
|
604
|
+
reasons.push(`${op.id}: shell command replay is forbidden for autopilot.`);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (op.kind.startsWith("tmux.") || op.kind === "app.open" || op.kind === "process.restart") {
|
|
608
|
+
approvalRequiredOperationIds.push(op.id);
|
|
609
|
+
reasons.push(`${op.id}: ${op.kind} requires human approval.`);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
approvalRequiredOperationIds.push(op.id);
|
|
613
|
+
reasons.push(`${op.id}: operation kind is not autopilot-allowlisted.`);
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
safeToApply: approvalRequiredOperationIds.length === 0 && forbiddenOperationIds.length === 0,
|
|
617
|
+
allowedOperationIds,
|
|
618
|
+
approvalRequiredOperationIds,
|
|
619
|
+
forbiddenOperationIds,
|
|
620
|
+
reasons
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function hashRestorePlan(plan) {
|
|
624
|
+
return sha256(stableJson({
|
|
625
|
+
snapshotId: plan.snapshotId,
|
|
626
|
+
request: {
|
|
627
|
+
include: plan.request?.include ?? [],
|
|
628
|
+
exclude: plan.request?.exclude ?? [],
|
|
629
|
+
dependencyMode: plan.request?.dependencyMode ?? "none",
|
|
630
|
+
targetMode: plan.request?.targetMode ?? "strict",
|
|
631
|
+
tmuxMode: plan.request?.tmuxMode ?? "layout-only",
|
|
632
|
+
applyPlanId: plan.request?.applyPlanId ?? null,
|
|
633
|
+
planHash: plan.request?.planHash ?? null
|
|
634
|
+
},
|
|
635
|
+
operations: plan.operations.map((op) => ({
|
|
636
|
+
id: op.id,
|
|
637
|
+
kind: op.kind,
|
|
638
|
+
resourceId: op.resourceId,
|
|
639
|
+
resourceKind: op.resourceKind,
|
|
640
|
+
status: op.status,
|
|
641
|
+
command: op.command ?? [],
|
|
642
|
+
reason: op.reason ?? null,
|
|
643
|
+
resourceHash: op.resource?.hash ?? null
|
|
644
|
+
}))
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
export function prepareRestorePlanForExecution(plan) {
|
|
648
|
+
const operations = plan.operations.map((op) => op.status === "blocked" && op.reason === "Restore execution requires --apply --yes."
|
|
649
|
+
? { ...op, status: "planned", reason: undefined }
|
|
650
|
+
: op);
|
|
651
|
+
return {
|
|
652
|
+
...plan,
|
|
653
|
+
apply: false,
|
|
654
|
+
operations,
|
|
655
|
+
summary: summarizeOperations(operations),
|
|
656
|
+
autopilot: assessAutopilot(operations)
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
function uniqueStrings(values) {
|
|
660
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
661
|
+
}
|
|
313
662
|
function summarizeOperations(operations) {
|
|
314
663
|
const summary = {
|
|
315
664
|
planned: 0,
|
|
@@ -328,6 +677,7 @@ function sortOperations(operations) {
|
|
|
328
677
|
const priority = {
|
|
329
678
|
"project.mkdir": 10,
|
|
330
679
|
"tmux.create-session": 20,
|
|
680
|
+
"tmux.move-window": 25,
|
|
331
681
|
"tmux.create-window": 30,
|
|
332
682
|
"tmux.create-pane": 35,
|
|
333
683
|
"tmux.select-layout": 36,
|
|
@@ -336,11 +686,26 @@ function sortOperations(operations) {
|
|
|
336
686
|
"process.restart": 40,
|
|
337
687
|
"app.open": 50
|
|
338
688
|
};
|
|
339
|
-
return [...operations].sort((a, b) => (priority[a.kind] ?? 100) - (priority[b.kind] ?? 100)
|
|
689
|
+
return [...operations].sort((a, b) => (priority[a.kind] ?? 100) - (priority[b.kind] ?? 100)
|
|
690
|
+
|| operationOrderKey(a).localeCompare(operationOrderKey(b), undefined, { numeric: true }));
|
|
340
691
|
}
|
|
341
|
-
function
|
|
342
|
-
const
|
|
343
|
-
|
|
692
|
+
function operationOrderKey(op) {
|
|
693
|
+
const resource = op.resource;
|
|
694
|
+
const session = typeof resource?.attributes.session === "string" ? resource.attributes.session : resource?.name ?? "";
|
|
695
|
+
const windowIndex = Number(resource?.attributes.index ?? resource?.attributes.window_index ?? 0);
|
|
696
|
+
const paneIndex = Number(resource?.attributes.pane_index ?? 0);
|
|
697
|
+
return `${session}:${Number.isFinite(windowIndex) ? windowIndex : 0}:${Number.isFinite(paneIndex) ? paneIndex : 0}:${op.id}`;
|
|
698
|
+
}
|
|
699
|
+
function tmuxWindowExists(session, name, index) {
|
|
700
|
+
const result = runTmux(["list-windows", "-t", session, "-F", "#{window_index}\t#{window_name}"], 2_000);
|
|
701
|
+
if (result.status !== 0)
|
|
702
|
+
return false;
|
|
703
|
+
return result.stdout.split("\n").some((line) => {
|
|
704
|
+
const [windowIndex, windowName] = line.trim().split("\t");
|
|
705
|
+
if (typeof index === "number")
|
|
706
|
+
return Number(windowIndex) === index;
|
|
707
|
+
return windowName === name;
|
|
708
|
+
});
|
|
344
709
|
}
|
|
345
710
|
function firstPaneIndexForWindow(resource, resources) {
|
|
346
711
|
const session = resource.attributes.session;
|