opencode-session-port 0.1.4 → 0.1.5
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 +8 -6
- package/dist/tui.js +121 -17
- package/package.json +1 -1
- package/src/tui/artifacts/files.ts +94 -1
- package/src/tui/wiring/composition.ts +54 -17
- package/src/tui/wiring/index.ts +2 -0
- package/src/tui/wiring/install.ts +2 -1
- package/src/tui/wiring/reports.ts +112 -15
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
SessionPort is a local plugin for the OpenCode TUI. It captures the sessions of your current project into a single password-encrypted artifact and restores them in another checkout, worktree, or machine. Artifacts travel with Git or any other channel you choose — SessionPort never runs Git commands itself.
|
|
6
6
|
|
|
7
|
-
> **Status: experimental pre-release (0.1.
|
|
7
|
+
> **Status: experimental pre-release (0.1.5).** SessionPort completed one bounded synthetic checkpoint/restore round trip on Windows with OpenCode v2.0.22. That test used test-only password and project-selector seams and staged source, not the npm tarball. On one real Windows OpenCode v2.0.22 host, a bounded diagnostic observed both commands registered (`commands=yes`) but hidden from the command palette and slash list until the keymap layer opted into global mode; 0.1.2 applies that global-mode registration fix, with focused command-registration tests and the TUI build passing. 0.1.3 additionally enables the commands on macOS and Linux through an explicit platform allowlist (`win32`/`darwin`/`linux`). 0.1.2 and 0.1.3 are published to npm (0.1.3 latest, per a read-only registry check); 0.1.4 and 0.1.5 are local candidates (0.1.5 current). 0.1.4 was a diagnostics/copy patch: a failed checkpoint collection appends a fixed sanitized reason and the known machine code (`Reason: <fixed reason> (<code>).`) for the current collection codes; unknown codes keep the generic text and raw host/exception text is never shown. 0.1.5 adds a bare `/restore` default that uses the newest checkpoint in the current project's `.sessionport/` directory (newest file modification time; no fallback to an older artifact on a corrupt file, wrong password, or read failure) and extends the same fixed-reason diagnostics to known restore plan/collection error codes. Collector behavior is unchanged, and no root cause is established for an earlier Windows collection failure; the concrete code behind an earlier restore rejection is likewise still pending a user runtime observation, and no planner correctness fix is claimed. macOS has one user-reported successful checkpoint only; Linux is untested; restore and cross-OS artifact interchange remain unestablished. End-to-end execution from the packed package has not been host-tested. Real modal interactions and loading the packed package in OpenCode have not been verified. The checkpoint format may change; no compatibility, security, or platform-support guarantee is made.
|
|
8
8
|
|
|
9
9
|
## Commands
|
|
10
10
|
|
|
11
11
|
- **`/checkpoint [artifact-path]`** — collects the current project's sessions as returned by the host (child/subagent and archived sessions included when the host returns them) and writes one encrypted artifact. It fails cleanly, with no artifact, when the set is empty, a session cannot be captured completely, or the output file already exists.
|
|
12
|
-
- **`/restore
|
|
12
|
+
- **`/restore [artifact-path]`** — decrypts and validates the artifact before session imports, then restores it into an existing project you select. Without a path, `/restore` uses the newest checkpoint in the current project's `.sessionport/` directory (newest file modification time; an exact tie is broken deterministically by filename). Restore does not overwrite existing sessions: exact destination matches are skipped, conflicting IDs are remapped deterministically, and parents are imported before children.
|
|
13
13
|
|
|
14
14
|
Both commands are registered by the plugin as `sessionport.checkpoint` and `sessionport.restore`, and appear in the slash-command list and the command palette.
|
|
15
15
|
|
|
@@ -24,17 +24,17 @@ Add the plugin to the `plugins` array in your global CLI-only config (`cli.json`
|
|
|
24
24
|
|
|
25
25
|
```json
|
|
26
26
|
{
|
|
27
|
-
"plugins": ["opencode-session-port@0.1.
|
|
27
|
+
"plugins": ["opencode-session-port@0.1.5"]
|
|
28
28
|
}
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
Or install the package plugin with the OpenCode CLI:
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
opencode plugin add opencode-session-port@0.1.
|
|
34
|
+
opencode plugin add opencode-session-port@0.1.5
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
`cli.json` is the documented OpenCode v2 config for CLI-only plugins: they run locally in the terminal and remain active when the CLI connects to a remote server. The npm registry shows 0.1.2 and 0.1.3 published (0.1.3 latest; read-only registry check); no published artifact has been loaded by OpenCode here, and no local package-verification record exists for the published 0.1.3 tarball. The 0.1.1 tarball passed npm consumer checks, but a direct Bun package-manager install timed out in the verification environment, so that install path remains unverified. The 0.1.2 tarball passed manifest/allowlist and packed-entry checks only. The 0.1.4 candidate
|
|
37
|
+
`cli.json` is the documented OpenCode v2 config for CLI-only plugins: they run locally in the terminal and remain active when the CLI connects to a remote server. The npm registry shows 0.1.2 and 0.1.3 published (0.1.3 latest; read-only registry check); no published artifact has been loaded by OpenCode here, and no local package-verification record exists for the published 0.1.3 tarball. The 0.1.1 tarball passed npm consumer checks, but a direct Bun package-manager install timed out in the verification environment, so that install path remains unverified. The 0.1.2 tarball passed manifest/allowlist and packed-entry checks only. The 0.1.4 and 0.1.5 candidate tarballs passed the same local manifest/allowlist and packed-entry checks (0.1.5 including the bare-restore latest-checkpoint resolver and the restore reason-copy allowlists); neither is published by this repository lane.
|
|
38
38
|
|
|
39
39
|
## Usage
|
|
40
40
|
|
|
@@ -51,9 +51,11 @@ Checkpoint prompts for a password twice in a masked input (paste is allowed); bo
|
|
|
51
51
|
### Restore
|
|
52
52
|
|
|
53
53
|
```text
|
|
54
|
-
/restore
|
|
54
|
+
/restore [artifact-path]
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
Without an argument, `/restore` picks the newest checkpoint in the current project's `.sessionport/` directory (newest file modification time wins; an exact tie is broken deterministically by filename, descending). If the project has no active directory or no checkpoint is found, nothing is changed. Restore never falls back to an older checkpoint: a corrupt file, wrong password, or read failure stops the command with an error instead of trying another artifact.
|
|
58
|
+
|
|
57
59
|
Restore first asks which existing project to import into, then prompts for the password (up to three attempts). The artifact is fully checked before the first session import. Project resolution may still update bounded host project metadata through OpenCode's public `location.get` API. Imports run parents-first, one session at a time: independent branches continue after a failure, descendants of a failed parent are blocked, and outcomes are reported per session. There is no atomic rollback. A later manual rerun may skip exact matching records, but exact retry behavior on a real host has not been established. Imported sessions are not opened or navigated to automatically.
|
|
58
60
|
|
|
59
61
|
### Moving artifacts
|
package/dist/tui.js
CHANGED
|
@@ -5710,7 +5710,7 @@ var init_experimental_profile = __esm(() => {
|
|
|
5710
5710
|
});
|
|
5711
5711
|
|
|
5712
5712
|
// src/tui/artifacts/files.ts
|
|
5713
|
-
import { lstat, mkdir, open, unlink } from "fs/promises";
|
|
5713
|
+
import { lstat, mkdir, open, readdir, unlink } from "fs/promises";
|
|
5714
5714
|
import * as path from "path";
|
|
5715
5715
|
async function resolveCheckpointOutputPath(options) {
|
|
5716
5716
|
const input = options;
|
|
@@ -5761,6 +5761,51 @@ async function assertPathDoesNotExist(candidate) {
|
|
|
5761
5761
|
function isErrnoCode(value, code) {
|
|
5762
5762
|
return typeof value === "object" && value !== null && value.code === code;
|
|
5763
5763
|
}
|
|
5764
|
+
async function resolveLatestCheckpointArtifact(options) {
|
|
5765
|
+
const input = options;
|
|
5766
|
+
if (typeof input !== "object" || input === null) {
|
|
5767
|
+
throw new ArtifactFileError("invalid-request", "resolveLatestCheckpointArtifact options must be an object");
|
|
5768
|
+
}
|
|
5769
|
+
const projectDirectory = requireNonEmptyString3(input.projectDirectory, "projectDirectory");
|
|
5770
|
+
const directory = path.resolve(projectDirectory, CHECKPOINT_DIRECTORY_NAME);
|
|
5771
|
+
let directoryStats;
|
|
5772
|
+
try {
|
|
5773
|
+
directoryStats = await lstat(directory);
|
|
5774
|
+
} catch (error) {
|
|
5775
|
+
if (isErrnoCode(error, "ENOENT"))
|
|
5776
|
+
return;
|
|
5777
|
+
throw error;
|
|
5778
|
+
}
|
|
5779
|
+
if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
|
|
5780
|
+
throw new ArtifactFileError("invalid-request", "The project's .sessionport checkpoint location is not a regular directory; refusing to scan it");
|
|
5781
|
+
}
|
|
5782
|
+
let bestName;
|
|
5783
|
+
let bestMtimeMs = Number.NEGATIVE_INFINITY;
|
|
5784
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
5785
|
+
if (!entry.name.endsWith(CHECKPOINT_FILENAME_EXTENSION))
|
|
5786
|
+
continue;
|
|
5787
|
+
if (entry.isDirectory() || entry.isSymbolicLink())
|
|
5788
|
+
continue;
|
|
5789
|
+
let candidateStats;
|
|
5790
|
+
try {
|
|
5791
|
+
candidateStats = await lstat(path.join(directory, entry.name));
|
|
5792
|
+
} catch (error) {
|
|
5793
|
+
if (isErrnoCode(error, "ENOENT"))
|
|
5794
|
+
continue;
|
|
5795
|
+
throw error;
|
|
5796
|
+
}
|
|
5797
|
+
if (!candidateStats.isFile())
|
|
5798
|
+
continue;
|
|
5799
|
+
const mtimeMs = candidateStats.mtimeMs;
|
|
5800
|
+
if (!Number.isFinite(mtimeMs))
|
|
5801
|
+
continue;
|
|
5802
|
+
if (bestName === undefined || mtimeMs > bestMtimeMs || mtimeMs === bestMtimeMs && entry.name > bestName) {
|
|
5803
|
+
bestName = entry.name;
|
|
5804
|
+
bestMtimeMs = mtimeMs;
|
|
5805
|
+
}
|
|
5806
|
+
}
|
|
5807
|
+
return bestName === undefined ? undefined : path.join(directory, bestName);
|
|
5808
|
+
}
|
|
5764
5809
|
async function writeEncryptedArtifact(targetPath, source) {
|
|
5765
5810
|
const pathString = requireNonEmptyString3(targetPath, "path");
|
|
5766
5811
|
const handle = await open(pathString, "wx");
|
|
@@ -5886,18 +5931,35 @@ function checkpointFailure(message) {
|
|
|
5886
5931
|
return { title: "Checkpoint failed", message, variant: "error" };
|
|
5887
5932
|
}
|
|
5888
5933
|
function checkpointCollectionFailureMessage(error) {
|
|
5934
|
+
return withKnownCollectionReason(error, CHECKPOINT_COLLECTION_FAILURE);
|
|
5935
|
+
}
|
|
5936
|
+
function withKnownCollectionReason(error, base) {
|
|
5889
5937
|
if (!(error instanceof SessionCollectionError))
|
|
5890
|
-
return
|
|
5938
|
+
return base;
|
|
5891
5939
|
const code = error.code;
|
|
5892
|
-
if (!Object.hasOwn(
|
|
5893
|
-
return
|
|
5894
|
-
const reason =
|
|
5895
|
-
return `${
|
|
5940
|
+
if (!Object.hasOwn(COLLECTION_FAILURE_REASONS, code))
|
|
5941
|
+
return base;
|
|
5942
|
+
const reason = COLLECTION_FAILURE_REASONS[code];
|
|
5943
|
+
return `${base} Reason: ${reason} (${code}).`;
|
|
5896
5944
|
}
|
|
5897
5945
|
function reportRestoreUsage() {
|
|
5898
5946
|
return {
|
|
5899
|
-
title: "Restore
|
|
5900
|
-
message: "Usage: /restore
|
|
5947
|
+
title: "Restore usage",
|
|
5948
|
+
message: "Usage: /restore [artifact-path]. Without a path, the newest checkpoint in the current project's .sessionport directory is used. No sessions were changed.",
|
|
5949
|
+
variant: "warning"
|
|
5950
|
+
};
|
|
5951
|
+
}
|
|
5952
|
+
function reportRestoreNoActiveProject() {
|
|
5953
|
+
return {
|
|
5954
|
+
title: "Restore unavailable",
|
|
5955
|
+
message: "The active project directory is unavailable, so the latest checkpoint could not be found; no sessions were changed.",
|
|
5956
|
+
variant: "warning"
|
|
5957
|
+
};
|
|
5958
|
+
}
|
|
5959
|
+
function reportRestoreNoCheckpointFound() {
|
|
5960
|
+
return {
|
|
5961
|
+
title: "No checkpoint found",
|
|
5962
|
+
message: "No checkpoint artifact was found in the current project's .sessionport directory; no sessions were changed. Create one with /checkpoint or pass an artifact path to /restore.",
|
|
5901
5963
|
variant: "warning"
|
|
5902
5964
|
};
|
|
5903
5965
|
}
|
|
@@ -5922,6 +5984,18 @@ function reportRestoreAuthenticationFailed() {
|
|
|
5922
5984
|
variant: "error"
|
|
5923
5985
|
};
|
|
5924
5986
|
}
|
|
5987
|
+
function restoreCollectionFailureMessage(error) {
|
|
5988
|
+
return withKnownCollectionReason(error, RESTORE_COLLECTION_FAILURE);
|
|
5989
|
+
}
|
|
5990
|
+
function restorePlanFailureMessage(error) {
|
|
5991
|
+
if (!(error instanceof RestorePlanError))
|
|
5992
|
+
return RESTORE_PLAN_FAILURE;
|
|
5993
|
+
const code = error.code;
|
|
5994
|
+
if (!Object.hasOwn(RESTORE_PLAN_REASONS, code))
|
|
5995
|
+
return RESTORE_PLAN_FAILURE;
|
|
5996
|
+
const reason = RESTORE_PLAN_REASONS[code];
|
|
5997
|
+
return `${RESTORE_PLAN_FAILURE} Reason: ${reason} (${code}).`;
|
|
5998
|
+
}
|
|
5925
5999
|
function reportRestoreFailure(error, executionStarted) {
|
|
5926
6000
|
if (executionStarted) {
|
|
5927
6001
|
return {
|
|
@@ -5941,9 +6015,9 @@ function reportRestoreFailure(error, executionStarted) {
|
|
|
5941
6015
|
case "destinations":
|
|
5942
6016
|
return restoreFailure("The host project list could not be read; no sessions were changed.");
|
|
5943
6017
|
case "collection":
|
|
5944
|
-
return restoreFailure(
|
|
6018
|
+
return restoreFailure(restoreCollectionFailureMessage(error));
|
|
5945
6019
|
case "plan":
|
|
5946
|
-
return restoreFailure(
|
|
6020
|
+
return restoreFailure(restorePlanFailureMessage(error));
|
|
5947
6021
|
case "artifact-path":
|
|
5948
6022
|
return restoreFailure("The artifact path is not usable; no sessions were changed.");
|
|
5949
6023
|
case "path-exists":
|
|
@@ -6048,7 +6122,7 @@ function classifyFailure(error) {
|
|
|
6048
6122
|
}
|
|
6049
6123
|
return "unknown";
|
|
6050
6124
|
}
|
|
6051
|
-
var CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed; no artifact was written.",
|
|
6125
|
+
var CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed; no artifact was written.", COLLECTION_FAILURE_REASONS, RESTORE_COLLECTION_FAILURE = "The destination project's sessions could not be read; no sessions were changed.", RESTORE_PLAN_FAILURE = "The checkpoint does not match this destination safely; no sessions were changed.", RESTORE_PLAN_REASONS;
|
|
6052
6126
|
var init_reports = __esm(() => {
|
|
6053
6127
|
init_files();
|
|
6054
6128
|
init_adapters();
|
|
@@ -6057,7 +6131,7 @@ var init_reports = __esm(() => {
|
|
|
6057
6131
|
init_planner();
|
|
6058
6132
|
init_sessions();
|
|
6059
6133
|
init_orchestration();
|
|
6060
|
-
|
|
6134
|
+
COLLECTION_FAILURE_REASONS = {
|
|
6061
6135
|
"invalid-request": "the session collection request was invalid",
|
|
6062
6136
|
"invalid-policy": "the session collection policy was invalid",
|
|
6063
6137
|
"session-list-failed": "the host session list could not be read",
|
|
@@ -6076,6 +6150,20 @@ var init_reports = __esm(() => {
|
|
|
6076
6150
|
"total-message-limit-exceeded": "the total message count exceeded the configured limit",
|
|
6077
6151
|
"unsettled-message": "the project history contains an unfinished message"
|
|
6078
6152
|
};
|
|
6153
|
+
RESTORE_PLAN_REASONS = {
|
|
6154
|
+
"invalid-input": "the restore planning request was invalid",
|
|
6155
|
+
"malformed-payload": "the checkpoint payload was malformed",
|
|
6156
|
+
"malformed-destination": "the destination session history could not be read",
|
|
6157
|
+
"divergent-artifact-session": "the checkpoint repeats a session with different content",
|
|
6158
|
+
"duplicate-artifact-message-id": "the checkpoint repeats a message ID across sessions",
|
|
6159
|
+
"ambiguous-destination": "the destination repeats a session or message ID",
|
|
6160
|
+
"dangling-session-reference": "a session reference does not resolve in the destination or plan",
|
|
6161
|
+
"dangling-message-reference": "a message reference does not resolve in the destination",
|
|
6162
|
+
"session-id-collision": "a planned session ID collides with existing session history",
|
|
6163
|
+
"message-id-collision": "a planned message ID collides with existing session history",
|
|
6164
|
+
"dependency-cycle": "session parent/reference relationships contain a cycle",
|
|
6165
|
+
"derivation-failure": "a remapped session or message ID could not be derived"
|
|
6166
|
+
};
|
|
6079
6167
|
});
|
|
6080
6168
|
|
|
6081
6169
|
// src/tui/wiring/composition.ts
|
|
@@ -6088,6 +6176,7 @@ function createSessionPortHandlers(dependencies) {
|
|
|
6088
6176
|
requireCallable(dependencies.showRestoreReport, "showRestoreReport");
|
|
6089
6177
|
requireCallable(dependencies.requestPassword, "requestPassword");
|
|
6090
6178
|
requireCallable(dependencies.resolveCheckpointOutputPath, "resolveCheckpointOutputPath");
|
|
6179
|
+
requireCallable(dependencies.resolveLatestCheckpointArtifact, "resolveLatestCheckpointArtifact");
|
|
6091
6180
|
requireCallable(dependencies.writeEncryptedArtifact, "writeEncryptedArtifact");
|
|
6092
6181
|
requireCallable(dependencies.readEncryptedArtifact, "readEncryptedArtifact");
|
|
6093
6182
|
if (!isRecordValue(dependencies.cryptoFactory) || typeof dependencies.cryptoFactory.create !== "function") {
|
|
@@ -6134,11 +6223,7 @@ async function runCheckpointCommand(dependencies, rawInput) {
|
|
|
6134
6223
|
}
|
|
6135
6224
|
}
|
|
6136
6225
|
async function runRestoreCommand(dependencies, rawInput) {
|
|
6137
|
-
const
|
|
6138
|
-
if (artifactPath === undefined) {
|
|
6139
|
-
dependencies.notify(reportRestoreUsage());
|
|
6140
|
-
return;
|
|
6141
|
-
}
|
|
6226
|
+
const explicitArtifactPath = parseRestoreArtifactArgument(rawInput);
|
|
6142
6227
|
const capability = probeSessionImportCapability(dependencies.client);
|
|
6143
6228
|
if (!capability.available) {
|
|
6144
6229
|
dependencies.notify(reportRestoreCapabilityUnavailable());
|
|
@@ -6146,6 +6231,22 @@ async function runRestoreCommand(dependencies, rawInput) {
|
|
|
6146
6231
|
}
|
|
6147
6232
|
let executionStarted = false;
|
|
6148
6233
|
try {
|
|
6234
|
+
let artifactPath;
|
|
6235
|
+
if (explicitArtifactPath !== undefined) {
|
|
6236
|
+
artifactPath = explicitArtifactPath;
|
|
6237
|
+
} else {
|
|
6238
|
+
const activeDirectory = dependencies.getActiveDirectory();
|
|
6239
|
+
if (typeof activeDirectory !== "string" || activeDirectory.trim().length === 0) {
|
|
6240
|
+
dependencies.notify(reportRestoreNoActiveProject());
|
|
6241
|
+
return;
|
|
6242
|
+
}
|
|
6243
|
+
const latest = await dependencies.resolveLatestCheckpointArtifact({ projectDirectory: activeDirectory });
|
|
6244
|
+
if (latest === undefined) {
|
|
6245
|
+
dependencies.notify(reportRestoreNoCheckpointFound());
|
|
6246
|
+
return;
|
|
6247
|
+
}
|
|
6248
|
+
artifactPath = latest;
|
|
6249
|
+
}
|
|
6149
6250
|
const destinations = await createProjectDestinationsAdapter(dependencies.client).listDestinations();
|
|
6150
6251
|
const selected = await selectRestoreProject(dependencies.dialog, destinations);
|
|
6151
6252
|
if (selected === undefined) {
|
|
@@ -10243,6 +10344,7 @@ async function installSessionPortWiring(context, options = {}) {
|
|
|
10243
10344
|
cryptoFactory,
|
|
10244
10345
|
idDeriver,
|
|
10245
10346
|
resolveCheckpointOutputPath,
|
|
10347
|
+
resolveLatestCheckpointArtifact,
|
|
10246
10348
|
writeEncryptedArtifact,
|
|
10247
10349
|
readEncryptedArtifact
|
|
10248
10350
|
});
|
|
@@ -10268,6 +10370,8 @@ var init_install = __esm(() => {
|
|
|
10268
10370
|
var exports_wiring = {};
|
|
10269
10371
|
__export(exports_wiring, {
|
|
10270
10372
|
reportRestoreUsage: () => reportRestoreUsage,
|
|
10373
|
+
reportRestoreNoCheckpointFound: () => reportRestoreNoCheckpointFound,
|
|
10374
|
+
reportRestoreNoActiveProject: () => reportRestoreNoActiveProject,
|
|
10271
10375
|
reportRestoreFailure: () => reportRestoreFailure,
|
|
10272
10376
|
reportRestoreExecution: () => reportRestoreExecution,
|
|
10273
10377
|
reportRestoreCapabilityUnavailable: () => reportRestoreCapabilityUnavailable,
|
package/package.json
CHANGED
|
@@ -40,10 +40,16 @@
|
|
|
40
40
|
* `:` from the UTC timestamp, no reserved device name), and drive-relative
|
|
41
41
|
* explicit paths (`C:foo`) fail closed instead of silently anchoring to the
|
|
42
42
|
* per-drive current directory.
|
|
43
|
+
* - `resolveLatestCheckpointArtifact` implements the bare `/restore` default:
|
|
44
|
+
* a non-recursive, content-free scan of `<projectDirectory>/.sessionport/`
|
|
45
|
+
* (newest `mtimeMs` wins; an exact tie is broken by filename, descending
|
|
46
|
+
* code-unit order). It never creates, opens, or reads a file, excludes
|
|
47
|
+
* symlink/swapped entries, and refuses a non-regular `.sessionport` entry
|
|
48
|
+
* instead of traversing it.
|
|
43
49
|
*/
|
|
44
50
|
|
|
45
51
|
/// <reference types="node" />
|
|
46
|
-
import { lstat, mkdir, open, unlink } from "node:fs/promises";
|
|
52
|
+
import { lstat, mkdir, open, readdir, unlink } from "node:fs/promises";
|
|
47
53
|
import * as path from "node:path";
|
|
48
54
|
import type { ByteSource } from "../../core/contracts/index.ts";
|
|
49
55
|
|
|
@@ -187,6 +193,93 @@ function isErrnoCode(value: unknown, code: string): boolean {
|
|
|
187
193
|
return typeof value === "object" && value !== null && (value as { readonly code?: unknown }).code === code;
|
|
188
194
|
}
|
|
189
195
|
|
|
196
|
+
/* ------------------------------------------------------------------------- */
|
|
197
|
+
/* Latest-checkpoint discovery (bare `/restore`) */
|
|
198
|
+
/* ------------------------------------------------------------------------- */
|
|
199
|
+
|
|
200
|
+
/** Options for `resolveLatestCheckpointArtifact`. */
|
|
201
|
+
export interface ResolveLatestCheckpointArtifactOptions {
|
|
202
|
+
/** Current project directory; the default checkpoint directory sits beneath it. */
|
|
203
|
+
readonly projectDirectory: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolves the newest default checkpoint artifact under
|
|
208
|
+
* `<projectDirectory>/.sessionport/`, or `undefined` when that directory is
|
|
209
|
+
* missing or contains no candidate. Selection is non-recursive and
|
|
210
|
+
* content-free: only entries whose name ends in the exact `.sessionport`
|
|
211
|
+
* suffix are considered (no prefix required), a candidate must be a regular
|
|
212
|
+
* file by `lstat` at selection time (symlinks and entries swapped after
|
|
213
|
+
* `readdir` are excluded), and only finite modification times qualify. The
|
|
214
|
+
* newest `mtimeMs` wins; an exact tie is broken by filename, descending
|
|
215
|
+
* UTF-16 code-unit order (deterministic, never locale-dependent). Nothing is
|
|
216
|
+
* created, opened, or read.
|
|
217
|
+
*
|
|
218
|
+
* Fail-closed behavior: a `.sessionport` entry that is not a regular
|
|
219
|
+
* directory (symlink/junction/file) is refused, not traversed. A missing
|
|
220
|
+
* directory, an empty directory, or a scan with no candidate resolves
|
|
221
|
+
* `undefined`; every other filesystem error (for example permissions)
|
|
222
|
+
* propagates unchanged to the caller's sanitized failure reporting. A
|
|
223
|
+
* candidate that disappears during the scan (ENOENT) is skipped; unrelated
|
|
224
|
+
* errors are never suppressed. This is a best-effort local discovery helper:
|
|
225
|
+
* it does not promise race-freedom against a malicious filesystem writer
|
|
226
|
+
* beyond the `lstat` re-check and the existing read safeguards.
|
|
227
|
+
*/
|
|
228
|
+
export async function resolveLatestCheckpointArtifact(
|
|
229
|
+
options: ResolveLatestCheckpointArtifactOptions,
|
|
230
|
+
): Promise<string | undefined> {
|
|
231
|
+
const input: unknown = options;
|
|
232
|
+
if (typeof input !== "object" || input === null) {
|
|
233
|
+
throw new ArtifactFileError("invalid-request", "resolveLatestCheckpointArtifact options must be an object");
|
|
234
|
+
}
|
|
235
|
+
const projectDirectory = requireNonEmptyString(
|
|
236
|
+
(input as { readonly projectDirectory?: unknown }).projectDirectory,
|
|
237
|
+
"projectDirectory",
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const directory = path.resolve(projectDirectory, CHECKPOINT_DIRECTORY_NAME);
|
|
241
|
+
let directoryStats;
|
|
242
|
+
try {
|
|
243
|
+
// lstat: a symlinked/junction directory is never followed or traversed.
|
|
244
|
+
directoryStats = await lstat(directory);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (isErrnoCode(error, "ENOENT")) return undefined;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
|
|
250
|
+
throw new ArtifactFileError(
|
|
251
|
+
"invalid-request",
|
|
252
|
+
"The project's .sessionport checkpoint location is not a regular directory; refusing to scan it",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let bestName: string | undefined;
|
|
257
|
+
let bestMtimeMs = Number.NEGATIVE_INFINITY;
|
|
258
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
259
|
+
if (!entry.name.endsWith(CHECKPOINT_FILENAME_EXTENSION)) continue;
|
|
260
|
+
// Dirent-level exclusion first; the lstat below is authoritative for
|
|
261
|
+
// the rest (including entries whose dirent type is unknown).
|
|
262
|
+
if (entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
263
|
+
let candidateStats;
|
|
264
|
+
try {
|
|
265
|
+
// Re-check after readdir: the entry may have been swapped for a
|
|
266
|
+
// symlink or a different object between the two calls.
|
|
267
|
+
candidateStats = await lstat(path.join(directory, entry.name));
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (isErrnoCode(error, "ENOENT")) continue;
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
if (!candidateStats.isFile()) continue;
|
|
273
|
+
const mtimeMs = candidateStats.mtimeMs;
|
|
274
|
+
if (!Number.isFinite(mtimeMs)) continue;
|
|
275
|
+
if (bestName === undefined || mtimeMs > bestMtimeMs || (mtimeMs === bestMtimeMs && entry.name > bestName)) {
|
|
276
|
+
bestName = entry.name;
|
|
277
|
+
bestMtimeMs = mtimeMs;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return bestName === undefined ? undefined : path.join(directory, bestName);
|
|
281
|
+
}
|
|
282
|
+
|
|
190
283
|
/* ------------------------------------------------------------------------- */
|
|
191
284
|
/* Exclusive write / lazy read */
|
|
192
285
|
/* ------------------------------------------------------------------------- */
|
|
@@ -12,10 +12,16 @@
|
|
|
12
12
|
* resolution, seal, exclusive encrypted write;
|
|
13
13
|
* 3. report path/size/session-count facts or a sanitized failure.
|
|
14
14
|
*
|
|
15
|
-
* `/restore
|
|
16
|
-
* 1.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* `/restore [artifact-path]`
|
|
16
|
+
* 1. probe `session.import` capability FIRST — before any filesystem
|
|
17
|
+
* discovery, artifact read, decrypt, or import (O-12, both forms);
|
|
18
|
+
* 2. when no artifact path is given (bare `/restore`), read the active
|
|
19
|
+
* project directory, fail closed with fixed copy when it is unavailable,
|
|
20
|
+
* and resolve the newest checkpoint in that project's `.sessionport/`
|
|
21
|
+
* directory through the injected resolver; no latest file reports a
|
|
22
|
+
* fixed no-checkpoint copy. An explicit path bypasses this lookup (and
|
|
23
|
+
* the active-directory read) entirely. No active `location.get` and no
|
|
24
|
+
* automatic destination selection are used for either form;
|
|
19
25
|
* 3. enumerate existing host projects via `project.list`, let the user select
|
|
20
26
|
* with `selectRestoreProject`;
|
|
21
27
|
* 4. call `location.get` ONLY for the selected candidate and require an exact
|
|
@@ -33,6 +39,9 @@
|
|
|
33
39
|
* empty result keeps the truthful non-success notification, and a
|
|
34
40
|
* dialog-display failure falls back to bounded aggregate-only copy.
|
|
35
41
|
*
|
|
42
|
+
* No fallback artifact: a corrupt file, wrong password, or read failure stops
|
|
43
|
+
* the command; an older checkpoint is never silently substituted.
|
|
44
|
+
*
|
|
36
45
|
* Hard boundaries: no shell/Git, no LLM/model call, no historical tool replay,
|
|
37
46
|
* no direct database access, no `project.update`; passwords stay masked and are
|
|
38
47
|
* owned/zeroed by the orchestration helpers; only already-sealed ciphertext is
|
|
@@ -47,7 +56,7 @@
|
|
|
47
56
|
import type { ByteSource, CheckpointCryptoFactory } from "../../core/contracts/index.ts";
|
|
48
57
|
import { collectCheckpointPayload } from "../../core/sessions/index.ts";
|
|
49
58
|
import type { RemappedIdDeriver } from "../../core/restore/index.ts";
|
|
50
|
-
import type { ResolveCheckpointOutputPathOptions } from "../artifacts/files.ts";
|
|
59
|
+
import type { ResolveCheckpointOutputPathOptions, ResolveLatestCheckpointArtifactOptions } from "../artifacts/files.ts";
|
|
51
60
|
import { createLocationResolver, LocationResolutionError } from "../adapters/location-resolver.ts";
|
|
52
61
|
import { createProjectDestinationsAdapter } from "../adapters/project-destinations.ts";
|
|
53
62
|
import { createSessionIdInventory } from "../adapters/session-id-inventory.ts";
|
|
@@ -78,7 +87,8 @@ import {
|
|
|
78
87
|
reportRestoreExecution,
|
|
79
88
|
reportRestoreExecutionFallback,
|
|
80
89
|
reportRestoreFailure,
|
|
81
|
-
|
|
90
|
+
reportRestoreNoActiveProject,
|
|
91
|
+
reportRestoreNoCheckpointFound,
|
|
82
92
|
type SessionPortNotification,
|
|
83
93
|
} from "./reports.ts";
|
|
84
94
|
|
|
@@ -118,6 +128,15 @@ export interface SessionPortHandlerDependencies {
|
|
|
118
128
|
readonly idDeriver: RemappedIdDeriver;
|
|
119
129
|
/** P-15 artifact output-path resolver. */
|
|
120
130
|
readonly resolveCheckpointOutputPath: (options: ResolveCheckpointOutputPathOptions) => Promise<string>;
|
|
131
|
+
/**
|
|
132
|
+
* Bare `/restore` latest-artifact resolver: newest `.sessionport` artifact
|
|
133
|
+
* under the project's default `.sessionport/` directory, or `undefined`
|
|
134
|
+
* when none exists. Production uses the filesystem resolver; tests inject a
|
|
135
|
+
* fake so no real discovery happens.
|
|
136
|
+
*/
|
|
137
|
+
readonly resolveLatestCheckpointArtifact: (
|
|
138
|
+
options: ResolveLatestCheckpointArtifactOptions,
|
|
139
|
+
) => Promise<string | undefined>;
|
|
121
140
|
/** Exclusive encrypted-artifact writer (ciphertext only). */
|
|
122
141
|
readonly writeEncryptedArtifact: (targetPath: string, source: ByteSource) => Promise<number>;
|
|
123
142
|
/** Lazy encrypted-artifact reader (no IO until iterated). */
|
|
@@ -142,6 +161,7 @@ export function createSessionPortHandlers(dependencies: SessionPortHandlerDepend
|
|
|
142
161
|
requireCallable(dependencies.showRestoreReport, "showRestoreReport");
|
|
143
162
|
requireCallable(dependencies.requestPassword, "requestPassword");
|
|
144
163
|
requireCallable(dependencies.resolveCheckpointOutputPath, "resolveCheckpointOutputPath");
|
|
164
|
+
requireCallable(dependencies.resolveLatestCheckpointArtifact, "resolveLatestCheckpointArtifact");
|
|
145
165
|
requireCallable(dependencies.writeEncryptedArtifact, "writeEncryptedArtifact");
|
|
146
166
|
requireCallable(dependencies.readEncryptedArtifact, "readEncryptedArtifact");
|
|
147
167
|
if (!isRecordValue(dependencies.cryptoFactory) || typeof dependencies.cryptoFactory.create !== "function") {
|
|
@@ -209,14 +229,12 @@ async function runRestoreCommand(
|
|
|
209
229
|
dependencies: SessionPortHandlerDependencies,
|
|
210
230
|
rawInput: string | undefined,
|
|
211
231
|
): Promise<void> {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
232
|
+
// `undefined` means no explicit path: bare `/restore` uses the newest
|
|
233
|
+
// checkpoint in the active project's `.sessionport/` directory.
|
|
234
|
+
const explicitArtifactPath = parseRestoreArtifactArgument(rawInput);
|
|
217
235
|
|
|
218
|
-
// O-12: capability absence is the first check — before any
|
|
219
|
-
// decrypt, destination enumeration, or import.
|
|
236
|
+
// O-12: capability absence is the first check — before any filesystem
|
|
237
|
+
// discovery, file read, decrypt, destination enumeration, or import.
|
|
220
238
|
const capability = probeSessionImportCapability(dependencies.client);
|
|
221
239
|
if (!capability.available) {
|
|
222
240
|
dependencies.notify(reportRestoreCapabilityUnavailable());
|
|
@@ -225,6 +243,24 @@ async function runRestoreCommand(
|
|
|
225
243
|
|
|
226
244
|
let executionStarted = false;
|
|
227
245
|
try {
|
|
246
|
+
let artifactPath: string;
|
|
247
|
+
if (explicitArtifactPath !== undefined) {
|
|
248
|
+
// Explicit paths bypass the active-project lookup entirely.
|
|
249
|
+
artifactPath = explicitArtifactPath;
|
|
250
|
+
} else {
|
|
251
|
+
const activeDirectory = dependencies.getActiveDirectory();
|
|
252
|
+
if (typeof activeDirectory !== "string" || activeDirectory.trim().length === 0) {
|
|
253
|
+
dependencies.notify(reportRestoreNoActiveProject());
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const latest = await dependencies.resolveLatestCheckpointArtifact({ projectDirectory: activeDirectory });
|
|
257
|
+
if (latest === undefined) {
|
|
258
|
+
dependencies.notify(reportRestoreNoCheckpointFound());
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
artifactPath = latest;
|
|
262
|
+
}
|
|
263
|
+
|
|
228
264
|
// Candidate enumeration and user selection happen before any location.get
|
|
229
265
|
// or artifact access; a canceled selection changes nothing.
|
|
230
266
|
const destinations = await createProjectDestinationsAdapter(dependencies.client).listDestinations();
|
|
@@ -342,10 +378,11 @@ export function parseCheckpointOutputArgument(rawInput: string | undefined): str
|
|
|
342
378
|
}
|
|
343
379
|
|
|
344
380
|
/**
|
|
345
|
-
* `/restore`
|
|
346
|
-
* all-whitespace argument
|
|
347
|
-
*
|
|
348
|
-
* whitespace and used verbatim
|
|
381
|
+
* `/restore` accepts one optional raw argument: the artifact path. `undefined`
|
|
382
|
+
* or an all-whitespace argument means the bare form (newest checkpoint in the
|
|
383
|
+
* active project's `.sessionport/` directory); any other argument is trimmed
|
|
384
|
+
* of surrounding whitespace and used verbatim (no flag parsing, no shell
|
|
385
|
+
* expansion, no quote handling).
|
|
349
386
|
*/
|
|
350
387
|
export function parseRestoreArtifactArgument(rawInput: string | undefined): string | undefined {
|
|
351
388
|
if (rawInput === undefined) return undefined;
|
package/src/tui/wiring/index.ts
CHANGED
|
@@ -47,6 +47,8 @@ export {
|
|
|
47
47
|
reportRestoreCapabilityUnavailable,
|
|
48
48
|
reportRestoreExecution,
|
|
49
49
|
reportRestoreFailure,
|
|
50
|
+
reportRestoreNoActiveProject,
|
|
51
|
+
reportRestoreNoCheckpointFound,
|
|
50
52
|
reportRestoreUsage,
|
|
51
53
|
} from "./reports.ts";
|
|
52
54
|
export type { SessionPortNotification, SessionPortNotificationVariant } from "./reports.ts";
|
|
@@ -41,7 +41,7 @@ import type { Plugin } from "@opencode/plugin/tui";
|
|
|
41
41
|
import type { CheckpointCryptoFactory } from "../../core/contracts/index.ts";
|
|
42
42
|
import { createCheckpointCryptoFactory } from "../../core/crypto/index.ts";
|
|
43
43
|
import { createRemappedIdDeriver, type RemappedIdDeriver } from "../../core/restore/index.ts";
|
|
44
|
-
import { readEncryptedArtifact, resolveCheckpointOutputPath, writeEncryptedArtifact } from "../artifacts/files.ts";
|
|
44
|
+
import { readEncryptedArtifact, resolveCheckpointOutputPath, resolveLatestCheckpointArtifact, writeEncryptedArtifact } from "../artifacts/files.ts";
|
|
45
45
|
import { requestPassword as showPasswordDialog } from "../ui/password-dialog.tsx";
|
|
46
46
|
import { showRestoreReportDialog } from "../ui/restore-report-dialog.tsx";
|
|
47
47
|
import { installSessionPortCommands } from "../ui/sessionport-commands.tsx";
|
|
@@ -99,6 +99,7 @@ export async function installSessionPortWiring(
|
|
|
99
99
|
cryptoFactory,
|
|
100
100
|
idDeriver,
|
|
101
101
|
resolveCheckpointOutputPath,
|
|
102
|
+
resolveLatestCheckpointArtifact,
|
|
102
103
|
writeEncryptedArtifact,
|
|
103
104
|
readEncryptedArtifact,
|
|
104
105
|
});
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* or exception text is NEVER interpolated. Classification uses stable error
|
|
6
6
|
* classes/codes only, never message text, so a failure report cannot leak
|
|
7
7
|
* session IDs, paths, host internals, or password material. Checkpoint
|
|
8
|
-
* collection failures
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* collection failures, restore collection failures, and restore plan failures
|
|
9
|
+
* additionally append a fixed human reason and the exact machine code, but
|
|
10
|
+
* only for `SessionCollectionError`/`RestorePlanError` codes on explicit
|
|
11
|
+
* allowlists; unknown or forged codes keep the generic text. Exact user-facing
|
|
11
12
|
* copy remains OPEN (OQ-15); these strings are the wiring lane's generic
|
|
12
13
|
* sanitized categories.
|
|
13
14
|
*
|
|
@@ -33,7 +34,7 @@ import { ArtifactFileError } from "../artifacts/files.ts";
|
|
|
33
34
|
import { HostAdapterError, ProjectDestinationError } from "../adapters/index.ts";
|
|
34
35
|
import { LocationResolutionError } from "../adapters/location-resolver.ts";
|
|
35
36
|
import { CryptoContainerError } from "../../core/crypto/errors.ts";
|
|
36
|
-
import { RestorePlanError } from "../../core/restore/planner.ts";
|
|
37
|
+
import { RestorePlanError, type RestorePlanErrorCode } from "../../core/restore/planner.ts";
|
|
37
38
|
import { SessionCollectionError, type SessionCollectionErrorCode } from "../../core/sessions/index.ts";
|
|
38
39
|
import { RESTORE_PASSWORD_ATTEMPT_LIMIT } from "../orchestration/index.ts";
|
|
39
40
|
import type { CheckpointOutcome, RestoreExecutionResult } from "../orchestration/index.ts";
|
|
@@ -124,13 +125,14 @@ const CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed;
|
|
|
124
125
|
|
|
125
126
|
/**
|
|
126
127
|
* Fixed human reason per known `SessionCollectionError` code. This record is
|
|
127
|
-
* the explicit allowlist
|
|
128
|
+
* the shared explicit allowlist for both the checkpoint and the restore
|
|
129
|
+
* collection category: the raw code is echoed to the user only when it
|
|
128
130
|
* exactly matches one of these keys, and the raw `error.message` (which can
|
|
129
131
|
* contain session/project IDs, paths, and host error text) is never used.
|
|
130
132
|
* Adding a code to `SessionCollectionErrorCode` fails compilation until a
|
|
131
133
|
* fixed reason is added here.
|
|
132
134
|
*/
|
|
133
|
-
const
|
|
135
|
+
const COLLECTION_FAILURE_REASONS: Readonly<Record<SessionCollectionErrorCode, string>> = {
|
|
134
136
|
"invalid-request": "the session collection request was invalid",
|
|
135
137
|
"invalid-policy": "the session collection policy was invalid",
|
|
136
138
|
"session-list-failed": "the host session list could not be read",
|
|
@@ -157,22 +159,64 @@ const CHECKPOINT_COLLECTION_REASONS: Readonly<Record<SessionCollectionErrorCode,
|
|
|
157
159
|
* text; only allowlisted literal code strings are ever echoed.
|
|
158
160
|
*/
|
|
159
161
|
function checkpointCollectionFailureMessage(error: unknown): string {
|
|
160
|
-
|
|
162
|
+
return withKnownCollectionReason(error, CHECKPOINT_COLLECTION_FAILURE);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Shared allowlist lookup for collection failures: appends
|
|
167
|
+
* `Reason: <fixed human reason> (<known code>).` to `base` for a
|
|
168
|
+
* `SessionCollectionError` whose code is on the fixed map. Any other error,
|
|
169
|
+
* non-instance object, or unknown/forged code keeps `base` unchanged; only
|
|
170
|
+
* allowlisted literal code strings are ever echoed.
|
|
171
|
+
*/
|
|
172
|
+
function withKnownCollectionReason(error: unknown, base: string): string {
|
|
173
|
+
if (!(error instanceof SessionCollectionError)) return base;
|
|
161
174
|
const code: string = error.code;
|
|
162
|
-
if (!Object.hasOwn(
|
|
163
|
-
const reason =
|
|
164
|
-
return `${
|
|
175
|
+
if (!Object.hasOwn(COLLECTION_FAILURE_REASONS, code)) return base;
|
|
176
|
+
const reason = COLLECTION_FAILURE_REASONS[code as SessionCollectionErrorCode];
|
|
177
|
+
return `${base} Reason: ${reason} (${code}).`;
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
/* ------------------------------------------------------------------------- */
|
|
168
181
|
/* Restore reports */
|
|
169
182
|
/* ------------------------------------------------------------------------- */
|
|
170
183
|
|
|
171
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Exported for compatibility. Bare `/restore` now resolves the newest
|
|
186
|
+
* checkpoint automatically, so the current wiring does not show this usage
|
|
187
|
+
* copy; it documents the optional artifact-path argument.
|
|
188
|
+
*/
|
|
172
189
|
export function reportRestoreUsage(): SessionPortNotification {
|
|
173
190
|
return {
|
|
174
|
-
title: "Restore
|
|
175
|
-
message:
|
|
191
|
+
title: "Restore usage",
|
|
192
|
+
message:
|
|
193
|
+
"Usage: /restore [artifact-path]. Without a path, the newest checkpoint in the current project's .sessionport directory is used. No sessions were changed.",
|
|
194
|
+
variant: "warning",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Bare `/restore` with no active project directory: fixed, specific copy with
|
|
200
|
+
* no raw path/error text; nothing was read or changed.
|
|
201
|
+
*/
|
|
202
|
+
export function reportRestoreNoActiveProject(): SessionPortNotification {
|
|
203
|
+
return {
|
|
204
|
+
title: "Restore unavailable",
|
|
205
|
+
message:
|
|
206
|
+
"The active project directory is unavailable, so the latest checkpoint could not be found; no sessions were changed.",
|
|
207
|
+
variant: "warning",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Bare `/restore` with no checkpoint in the active project's default
|
|
213
|
+
* `.sessionport/` directory: fixed friendly copy; nothing was read or changed.
|
|
214
|
+
*/
|
|
215
|
+
export function reportRestoreNoCheckpointFound(): SessionPortNotification {
|
|
216
|
+
return {
|
|
217
|
+
title: "No checkpoint found",
|
|
218
|
+
message:
|
|
219
|
+
"No checkpoint artifact was found in the current project's .sessionport directory; no sessions were changed. Create one with /checkpoint or pass an artifact path to /restore.",
|
|
176
220
|
variant: "warning",
|
|
177
221
|
};
|
|
178
222
|
}
|
|
@@ -205,6 +249,59 @@ export function reportRestoreAuthenticationFailed(): SessionPortNotification {
|
|
|
205
249
|
};
|
|
206
250
|
}
|
|
207
251
|
|
|
252
|
+
/** Existing generic restore collection failure copy; also the fallback for unknown codes. */
|
|
253
|
+
const RESTORE_COLLECTION_FAILURE = "The destination project's sessions could not be read; no sessions were changed.";
|
|
254
|
+
|
|
255
|
+
/** Existing generic restore plan failure copy; also the fallback for unknown codes. */
|
|
256
|
+
const RESTORE_PLAN_FAILURE = "The checkpoint does not match this destination safely; no sessions were changed.";
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Fixed human reason per known `RestorePlanError` code — the explicit
|
|
260
|
+
* allowlist for the restore plan category. As with collection codes, the raw
|
|
261
|
+
* code is echoed only on an exact key match and the raw `error.message` (which
|
|
262
|
+
* can contain session/message IDs and labels) is never used. Adding a code to
|
|
263
|
+
* `RestorePlanErrorCode` fails compilation until a fixed reason is added here.
|
|
264
|
+
*/
|
|
265
|
+
const RESTORE_PLAN_REASONS: Readonly<Record<RestorePlanErrorCode, string>> = {
|
|
266
|
+
"invalid-input": "the restore planning request was invalid",
|
|
267
|
+
"malformed-payload": "the checkpoint payload was malformed",
|
|
268
|
+
"malformed-destination": "the destination session history could not be read",
|
|
269
|
+
"divergent-artifact-session": "the checkpoint repeats a session with different content",
|
|
270
|
+
"duplicate-artifact-message-id": "the checkpoint repeats a message ID across sessions",
|
|
271
|
+
"ambiguous-destination": "the destination repeats a session or message ID",
|
|
272
|
+
"dangling-session-reference": "a session reference does not resolve in the destination or plan",
|
|
273
|
+
"dangling-message-reference": "a message reference does not resolve in the destination",
|
|
274
|
+
"session-id-collision": "a planned session ID collides with existing session history",
|
|
275
|
+
"message-id-collision": "a planned message ID collides with existing session history",
|
|
276
|
+
"dependency-cycle": "session parent/reference relationships contain a cycle",
|
|
277
|
+
"derivation-failure": "a remapped session or message ID could not be derived",
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Restore collection failure copy: the existing restore base message plus
|
|
282
|
+
* `Reason: <fixed human reason> (<known code>).` for a known
|
|
283
|
+
* `SessionCollectionError` code (shared allowlist with the checkpoint
|
|
284
|
+
* category). Unknown/forged codes and non-instance errors keep the base text;
|
|
285
|
+
* raw messages, IDs, and paths never leak.
|
|
286
|
+
*/
|
|
287
|
+
function restoreCollectionFailureMessage(error: unknown): string {
|
|
288
|
+
return withKnownCollectionReason(error, RESTORE_COLLECTION_FAILURE);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Restore plan failure copy: the existing restore base message plus
|
|
293
|
+
* `Reason: <fixed human reason> (<known code>).` for a `RestorePlanError`
|
|
294
|
+
* whose code is on the allowlist. Unknown/forged codes and non-instance
|
|
295
|
+
* errors keep the base text; raw plan messages (IDs/labels) never leak.
|
|
296
|
+
*/
|
|
297
|
+
function restorePlanFailureMessage(error: unknown): string {
|
|
298
|
+
if (!(error instanceof RestorePlanError)) return RESTORE_PLAN_FAILURE;
|
|
299
|
+
const code: string = error.code;
|
|
300
|
+
if (!Object.hasOwn(RESTORE_PLAN_REASONS, code)) return RESTORE_PLAN_FAILURE;
|
|
301
|
+
const reason = RESTORE_PLAN_REASONS[code as RestorePlanErrorCode];
|
|
302
|
+
return `${RESTORE_PLAN_FAILURE} Reason: ${reason} (${code}).`;
|
|
303
|
+
}
|
|
304
|
+
|
|
208
305
|
/**
|
|
209
306
|
* Reports one restore failure as a fixed, sanitized category. `executionStarted`
|
|
210
307
|
* is true only once the import executor was invoked; the copy never claims
|
|
@@ -232,9 +329,9 @@ export function reportRestoreFailure(error: unknown, executionStarted: boolean):
|
|
|
232
329
|
case "destinations":
|
|
233
330
|
return restoreFailure("The host project list could not be read; no sessions were changed.");
|
|
234
331
|
case "collection":
|
|
235
|
-
return restoreFailure(
|
|
332
|
+
return restoreFailure(restoreCollectionFailureMessage(error));
|
|
236
333
|
case "plan":
|
|
237
|
-
return restoreFailure(
|
|
334
|
+
return restoreFailure(restorePlanFailureMessage(error));
|
|
238
335
|
case "artifact-path":
|
|
239
336
|
return restoreFailure("The artifact path is not usable; no sessions were changed.");
|
|
240
337
|
case "path-exists":
|