opencode-session-port 0.1.3 → 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 +144 -11
- 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 +154 -9
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.
|
|
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");
|
|
@@ -5866,7 +5911,7 @@ function reportCheckpointFailure(error) {
|
|
|
5866
5911
|
case "capability":
|
|
5867
5912
|
return checkpointFailure("This OpenCode host does not expose the required session-listing capability; no artifact was written.");
|
|
5868
5913
|
case "collection":
|
|
5869
|
-
return checkpointFailure(
|
|
5914
|
+
return checkpointFailure(checkpointCollectionFailureMessage(error));
|
|
5870
5915
|
case "path-exists":
|
|
5871
5916
|
return checkpointFailure("An artifact already exists at that path; nothing was overwritten.");
|
|
5872
5917
|
case "artifact-path":
|
|
@@ -5885,10 +5930,36 @@ function reportCheckpointFailure(error) {
|
|
|
5885
5930
|
function checkpointFailure(message) {
|
|
5886
5931
|
return { title: "Checkpoint failed", message, variant: "error" };
|
|
5887
5932
|
}
|
|
5933
|
+
function checkpointCollectionFailureMessage(error) {
|
|
5934
|
+
return withKnownCollectionReason(error, CHECKPOINT_COLLECTION_FAILURE);
|
|
5935
|
+
}
|
|
5936
|
+
function withKnownCollectionReason(error, base) {
|
|
5937
|
+
if (!(error instanceof SessionCollectionError))
|
|
5938
|
+
return base;
|
|
5939
|
+
const code = error.code;
|
|
5940
|
+
if (!Object.hasOwn(COLLECTION_FAILURE_REASONS, code))
|
|
5941
|
+
return base;
|
|
5942
|
+
const reason = COLLECTION_FAILURE_REASONS[code];
|
|
5943
|
+
return `${base} Reason: ${reason} (${code}).`;
|
|
5944
|
+
}
|
|
5888
5945
|
function reportRestoreUsage() {
|
|
5889
5946
|
return {
|
|
5890
|
-
title: "Restore
|
|
5891
|
-
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.",
|
|
5892
5963
|
variant: "warning"
|
|
5893
5964
|
};
|
|
5894
5965
|
}
|
|
@@ -5913,6 +5984,18 @@ function reportRestoreAuthenticationFailed() {
|
|
|
5913
5984
|
variant: "error"
|
|
5914
5985
|
};
|
|
5915
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
|
+
}
|
|
5916
5999
|
function reportRestoreFailure(error, executionStarted) {
|
|
5917
6000
|
if (executionStarted) {
|
|
5918
6001
|
return {
|
|
@@ -5932,9 +6015,9 @@ function reportRestoreFailure(error, executionStarted) {
|
|
|
5932
6015
|
case "destinations":
|
|
5933
6016
|
return restoreFailure("The host project list could not be read; no sessions were changed.");
|
|
5934
6017
|
case "collection":
|
|
5935
|
-
return restoreFailure(
|
|
6018
|
+
return restoreFailure(restoreCollectionFailureMessage(error));
|
|
5936
6019
|
case "plan":
|
|
5937
|
-
return restoreFailure(
|
|
6020
|
+
return restoreFailure(restorePlanFailureMessage(error));
|
|
5938
6021
|
case "artifact-path":
|
|
5939
6022
|
return restoreFailure("The artifact path is not usable; no sessions were changed.");
|
|
5940
6023
|
case "path-exists":
|
|
@@ -6039,6 +6122,7 @@ function classifyFailure(error) {
|
|
|
6039
6122
|
}
|
|
6040
6123
|
return "unknown";
|
|
6041
6124
|
}
|
|
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;
|
|
6042
6126
|
var init_reports = __esm(() => {
|
|
6043
6127
|
init_files();
|
|
6044
6128
|
init_adapters();
|
|
@@ -6047,6 +6131,39 @@ var init_reports = __esm(() => {
|
|
|
6047
6131
|
init_planner();
|
|
6048
6132
|
init_sessions();
|
|
6049
6133
|
init_orchestration();
|
|
6134
|
+
COLLECTION_FAILURE_REASONS = {
|
|
6135
|
+
"invalid-request": "the session collection request was invalid",
|
|
6136
|
+
"invalid-policy": "the session collection policy was invalid",
|
|
6137
|
+
"session-list-failed": "the host session list could not be read",
|
|
6138
|
+
"message-list-failed": "a session's message history could not be read",
|
|
6139
|
+
"malformed-session-page": "the host returned a malformed session list page",
|
|
6140
|
+
"malformed-message-page": "the host returned a malformed message list page",
|
|
6141
|
+
"malformed-session-record": "the host returned a malformed session record",
|
|
6142
|
+
"malformed-message-record": "the host returned a malformed message record",
|
|
6143
|
+
"session-project-mismatch": "a listed session did not belong to the requested project",
|
|
6144
|
+
"duplicate-session-id": "the host returned a session more than once",
|
|
6145
|
+
"duplicate-message-id": "the host returned a message more than once",
|
|
6146
|
+
"repeated-session-cursor": "the host repeated a session list cursor",
|
|
6147
|
+
"repeated-message-cursor": "the host repeated a message list cursor",
|
|
6148
|
+
"session-limit-exceeded": "the session count exceeded the configured limit",
|
|
6149
|
+
"message-limit-exceeded": "a session's message count exceeded the configured limit",
|
|
6150
|
+
"total-message-limit-exceeded": "the total message count exceeded the configured limit",
|
|
6151
|
+
"unsettled-message": "the project history contains an unfinished message"
|
|
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
|
+
};
|
|
6050
6167
|
});
|
|
6051
6168
|
|
|
6052
6169
|
// src/tui/wiring/composition.ts
|
|
@@ -6059,6 +6176,7 @@ function createSessionPortHandlers(dependencies) {
|
|
|
6059
6176
|
requireCallable(dependencies.showRestoreReport, "showRestoreReport");
|
|
6060
6177
|
requireCallable(dependencies.requestPassword, "requestPassword");
|
|
6061
6178
|
requireCallable(dependencies.resolveCheckpointOutputPath, "resolveCheckpointOutputPath");
|
|
6179
|
+
requireCallable(dependencies.resolveLatestCheckpointArtifact, "resolveLatestCheckpointArtifact");
|
|
6062
6180
|
requireCallable(dependencies.writeEncryptedArtifact, "writeEncryptedArtifact");
|
|
6063
6181
|
requireCallable(dependencies.readEncryptedArtifact, "readEncryptedArtifact");
|
|
6064
6182
|
if (!isRecordValue(dependencies.cryptoFactory) || typeof dependencies.cryptoFactory.create !== "function") {
|
|
@@ -6105,11 +6223,7 @@ async function runCheckpointCommand(dependencies, rawInput) {
|
|
|
6105
6223
|
}
|
|
6106
6224
|
}
|
|
6107
6225
|
async function runRestoreCommand(dependencies, rawInput) {
|
|
6108
|
-
const
|
|
6109
|
-
if (artifactPath === undefined) {
|
|
6110
|
-
dependencies.notify(reportRestoreUsage());
|
|
6111
|
-
return;
|
|
6112
|
-
}
|
|
6226
|
+
const explicitArtifactPath = parseRestoreArtifactArgument(rawInput);
|
|
6113
6227
|
const capability = probeSessionImportCapability(dependencies.client);
|
|
6114
6228
|
if (!capability.available) {
|
|
6115
6229
|
dependencies.notify(reportRestoreCapabilityUnavailable());
|
|
@@ -6117,6 +6231,22 @@ async function runRestoreCommand(dependencies, rawInput) {
|
|
|
6117
6231
|
}
|
|
6118
6232
|
let executionStarted = false;
|
|
6119
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
|
+
}
|
|
6120
6250
|
const destinations = await createProjectDestinationsAdapter(dependencies.client).listDestinations();
|
|
6121
6251
|
const selected = await selectRestoreProject(dependencies.dialog, destinations);
|
|
6122
6252
|
if (selected === undefined) {
|
|
@@ -10214,6 +10344,7 @@ async function installSessionPortWiring(context, options = {}) {
|
|
|
10214
10344
|
cryptoFactory,
|
|
10215
10345
|
idDeriver,
|
|
10216
10346
|
resolveCheckpointOutputPath,
|
|
10347
|
+
resolveLatestCheckpointArtifact,
|
|
10217
10348
|
writeEncryptedArtifact,
|
|
10218
10349
|
readEncryptedArtifact
|
|
10219
10350
|
});
|
|
@@ -10239,6 +10370,8 @@ var init_install = __esm(() => {
|
|
|
10239
10370
|
var exports_wiring = {};
|
|
10240
10371
|
__export(exports_wiring, {
|
|
10241
10372
|
reportRestoreUsage: () => reportRestoreUsage,
|
|
10373
|
+
reportRestoreNoCheckpointFound: () => reportRestoreNoCheckpointFound,
|
|
10374
|
+
reportRestoreNoActiveProject: () => reportRestoreNoActiveProject,
|
|
10242
10375
|
reportRestoreFailure: () => reportRestoreFailure,
|
|
10243
10376
|
reportRestoreExecution: () => reportRestoreExecution,
|
|
10244
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
|
});
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* Every function here returns a fixed, generic notification object; raw host
|
|
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
|
-
* session IDs, paths, host internals, or password material.
|
|
7
|
+
* session IDs, paths, host internals, or password material. Checkpoint
|
|
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
|
|
8
12
|
* copy remains OPEN (OQ-15); these strings are the wiring lane's generic
|
|
9
13
|
* sanitized categories.
|
|
10
14
|
*
|
|
@@ -30,8 +34,8 @@ import { ArtifactFileError } from "../artifacts/files.ts";
|
|
|
30
34
|
import { HostAdapterError, ProjectDestinationError } from "../adapters/index.ts";
|
|
31
35
|
import { LocationResolutionError } from "../adapters/location-resolver.ts";
|
|
32
36
|
import { CryptoContainerError } from "../../core/crypto/errors.ts";
|
|
33
|
-
import { RestorePlanError } from "../../core/restore/planner.ts";
|
|
34
|
-
import { SessionCollectionError } from "../../core/sessions/index.ts";
|
|
37
|
+
import { RestorePlanError, type RestorePlanErrorCode } from "../../core/restore/planner.ts";
|
|
38
|
+
import { SessionCollectionError, type SessionCollectionErrorCode } from "../../core/sessions/index.ts";
|
|
35
39
|
import { RESTORE_PASSWORD_ATTEMPT_LIMIT } from "../orchestration/index.ts";
|
|
36
40
|
import type { CheckpointOutcome, RestoreExecutionResult } from "../orchestration/index.ts";
|
|
37
41
|
import type {
|
|
@@ -94,7 +98,7 @@ export function reportCheckpointFailure(error: unknown): SessionPortNotification
|
|
|
94
98
|
case "capability":
|
|
95
99
|
return checkpointFailure("This OpenCode host does not expose the required session-listing capability; no artifact was written.");
|
|
96
100
|
case "collection":
|
|
97
|
-
return checkpointFailure(
|
|
101
|
+
return checkpointFailure(checkpointCollectionFailureMessage(error));
|
|
98
102
|
case "path-exists":
|
|
99
103
|
return checkpointFailure("An artifact already exists at that path; nothing was overwritten.");
|
|
100
104
|
case "artifact-path":
|
|
@@ -116,15 +120,103 @@ function checkpointFailure(message: string): SessionPortNotification {
|
|
|
116
120
|
return { title: "Checkpoint failed", message, variant: "error" };
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
/** Existing generic checkpoint collection failure copy; also the fallback for unknown codes. */
|
|
124
|
+
const CHECKPOINT_COLLECTION_FAILURE = "Collecting the project's sessions failed; no artifact was written.";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fixed human reason per known `SessionCollectionError` code. This record is
|
|
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
|
|
130
|
+
* exactly matches one of these keys, and the raw `error.message` (which can
|
|
131
|
+
* contain session/project IDs, paths, and host error text) is never used.
|
|
132
|
+
* Adding a code to `SessionCollectionErrorCode` fails compilation until a
|
|
133
|
+
* fixed reason is added here.
|
|
134
|
+
*/
|
|
135
|
+
const COLLECTION_FAILURE_REASONS: Readonly<Record<SessionCollectionErrorCode, string>> = {
|
|
136
|
+
"invalid-request": "the session collection request was invalid",
|
|
137
|
+
"invalid-policy": "the session collection policy was invalid",
|
|
138
|
+
"session-list-failed": "the host session list could not be read",
|
|
139
|
+
"message-list-failed": "a session's message history could not be read",
|
|
140
|
+
"malformed-session-page": "the host returned a malformed session list page",
|
|
141
|
+
"malformed-message-page": "the host returned a malformed message list page",
|
|
142
|
+
"malformed-session-record": "the host returned a malformed session record",
|
|
143
|
+
"malformed-message-record": "the host returned a malformed message record",
|
|
144
|
+
"session-project-mismatch": "a listed session did not belong to the requested project",
|
|
145
|
+
"duplicate-session-id": "the host returned a session more than once",
|
|
146
|
+
"duplicate-message-id": "the host returned a message more than once",
|
|
147
|
+
"repeated-session-cursor": "the host repeated a session list cursor",
|
|
148
|
+
"repeated-message-cursor": "the host repeated a message list cursor",
|
|
149
|
+
"session-limit-exceeded": "the session count exceeded the configured limit",
|
|
150
|
+
"message-limit-exceeded": "a session's message count exceeded the configured limit",
|
|
151
|
+
"total-message-limit-exceeded": "the total message count exceeded the configured limit",
|
|
152
|
+
"unsettled-message": "the project history contains an unfinished message",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Checkpoint collection failure copy: the existing generic message plus
|
|
157
|
+
* `Reason: <fixed human reason> (<known code>).` for a `SessionCollectionError`
|
|
158
|
+
* whose code is on the allowlist. Any other error or code keeps the generic
|
|
159
|
+
* text; only allowlisted literal code strings are ever echoed.
|
|
160
|
+
*/
|
|
161
|
+
function checkpointCollectionFailureMessage(error: unknown): string {
|
|
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;
|
|
174
|
+
const code: string = error.code;
|
|
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}).`;
|
|
178
|
+
}
|
|
179
|
+
|
|
119
180
|
/* ------------------------------------------------------------------------- */
|
|
120
181
|
/* Restore reports */
|
|
121
182
|
/* ------------------------------------------------------------------------- */
|
|
122
183
|
|
|
123
|
-
/**
|
|
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
|
+
*/
|
|
124
189
|
export function reportRestoreUsage(): SessionPortNotification {
|
|
125
190
|
return {
|
|
126
|
-
title: "Restore
|
|
127
|
-
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.",
|
|
128
220
|
variant: "warning",
|
|
129
221
|
};
|
|
130
222
|
}
|
|
@@ -157,6 +249,59 @@ export function reportRestoreAuthenticationFailed(): SessionPortNotification {
|
|
|
157
249
|
};
|
|
158
250
|
}
|
|
159
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
|
+
|
|
160
305
|
/**
|
|
161
306
|
* Reports one restore failure as a fixed, sanitized category. `executionStarted`
|
|
162
307
|
* is true only once the import executor was invoked; the copy never claims
|
|
@@ -184,9 +329,9 @@ export function reportRestoreFailure(error: unknown, executionStarted: boolean):
|
|
|
184
329
|
case "destinations":
|
|
185
330
|
return restoreFailure("The host project list could not be read; no sessions were changed.");
|
|
186
331
|
case "collection":
|
|
187
|
-
return restoreFailure(
|
|
332
|
+
return restoreFailure(restoreCollectionFailureMessage(error));
|
|
188
333
|
case "plan":
|
|
189
|
-
return restoreFailure(
|
|
334
|
+
return restoreFailure(restorePlanFailureMessage(error));
|
|
190
335
|
case "artifact-path":
|
|
191
336
|
return restoreFailure("The artifact path is not usable; no sessions were changed.");
|
|
192
337
|
case "path-exists":
|