@rjfan/dsh-presence 0.19.0 → 0.20.1
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 +92 -23
- package/README.zh-CN.md +81 -20
- package/index.js +138 -89
- package/package.json +1 -1
- package/src/api.js +6 -1
- package/src/intercept.js +150 -0
- package/src/job-store.js +292 -0
- package/src/observed-store.js +136 -19
- package/src/pool.js +308 -86
- package/src/tools.js +845 -154
- package/src/wait.js +352 -0
package/src/job-store.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Persistent registry of background jobs.
|
|
2
|
+
//
|
|
3
|
+
// A detached job outlives the tool call that started it and the Harness
|
|
4
|
+
// process that started it. The live pool keeps jobs in memory for its lifetime;
|
|
5
|
+
// this JSON store is what makes them visible again after a restart. It lives
|
|
6
|
+
// under ~/.dsh/presence next to the other Presence stores, is written atomically
|
|
7
|
+
// (temp file + rename, 0600) and is bounded in size, so it cannot grow forever.
|
|
8
|
+
//
|
|
9
|
+
// Shapes (frozen by .scratch/feat-0.20/CONTRACTS-0.20.md):
|
|
10
|
+
// { id, host, label, command, sessionId, pid, pgid, logPath, commandLogPath,
|
|
11
|
+
// startedAt, exitedAt, exitCode, exitSignal, state }
|
|
12
|
+
//
|
|
13
|
+
// `state` is one of running | exited | unknown. A record read back from disk is
|
|
14
|
+
// never trusted as `running`: the pool demotes non-exited records to `unknown`
|
|
15
|
+
// until a pid liveness probe actually finds the process group.
|
|
16
|
+
//
|
|
17
|
+
// Deliberate additions for restart trust (both optional, documented in the
|
|
18
|
+
// 0.20 report): `statusPath` (the wrapper's exit-status file, without which a
|
|
19
|
+
// job that ended while the app was down could never report its exit code) and
|
|
20
|
+
// `cwd` (kept for the process listing/seed path).
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** Hard bound on the store, whatever the caller's prune settings say. */
|
|
27
|
+
export const MAX_JOB_RECORDS = 500;
|
|
28
|
+
/** Retention for finished jobs: a month is long enough to come back to a run. */
|
|
29
|
+
export const MAX_JOB_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
30
|
+
const MAX_SIZE = 100;
|
|
31
|
+
const MAX_COMMAND = 8 * 1024;
|
|
32
|
+
const MAX_PATH = 4 * 1024;
|
|
33
|
+
const MAX_LABEL = 160;
|
|
34
|
+
const MAX_SESSION = 200;
|
|
35
|
+
|
|
36
|
+
export const JOB_STATES = new Set(['running', 'exited', 'unknown']);
|
|
37
|
+
|
|
38
|
+
function storeDirectory() {
|
|
39
|
+
const base = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
40
|
+
return join(base, 'presence');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Absolute path of the job registry. `PRESENCE_JOBS_FILE` overrides it (probes). */
|
|
44
|
+
export function jobsFile() {
|
|
45
|
+
return process.env.PRESENCE_JOBS_FILE || join(storeDirectory(), 'jobs.json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function text(value, limit) {
|
|
49
|
+
if (typeof value !== 'string' || value === '') return null;
|
|
50
|
+
return value.length > limit ? value.slice(0, limit) : value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function numberOrNull(value) {
|
|
54
|
+
if (value === undefined || value === null || value === '') return null;
|
|
55
|
+
const number = Number(value);
|
|
56
|
+
return Number.isFinite(number) ? number : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stateOf(value) {
|
|
60
|
+
const state = String(value ?? '').toLowerCase();
|
|
61
|
+
if (state === 'stopped' || state === 'killed') return 'exited';
|
|
62
|
+
return JOB_STATES.has(state) ? state : 'unknown';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Tolerant reading of one stored record; a bad row is dropped instead of thrown. */
|
|
66
|
+
function recordFromStored(item) {
|
|
67
|
+
try {
|
|
68
|
+
if (!item || typeof item !== 'object') return null;
|
|
69
|
+
const id = text(item.id, MAX_SIZE);
|
|
70
|
+
const host = text(item.host, MAX_SIZE);
|
|
71
|
+
if (!id || !host) return null;
|
|
72
|
+
const pid = numberOrNull(item.pid);
|
|
73
|
+
const startedAt = numberOrNull(item.startedAt);
|
|
74
|
+
const exitedAt = numberOrNull(item.exitedAt);
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
host,
|
|
78
|
+
label: text(item.label, MAX_LABEL),
|
|
79
|
+
command: text(item.command, MAX_COMMAND),
|
|
80
|
+
sessionId: text(item.sessionId, MAX_SESSION),
|
|
81
|
+
pid: pid === null ? null : Math.trunc(pid),
|
|
82
|
+
pgid: numberOrNull(item.pgid) === null ? (pid === null ? null : Math.trunc(pid)) : Math.trunc(numberOrNull(item.pgid)),
|
|
83
|
+
logPath: text(item.logPath, MAX_PATH),
|
|
84
|
+
commandLogPath: text(item.commandLogPath, MAX_PATH),
|
|
85
|
+
statusPath: text(item.statusPath, MAX_PATH),
|
|
86
|
+
cwd: text(item.cwd, MAX_PATH),
|
|
87
|
+
startedAt: startedAt === null ? null : Math.trunc(startedAt),
|
|
88
|
+
exitedAt: exitedAt === null ? null : Math.trunc(exitedAt),
|
|
89
|
+
exitCode: numberOrNull(item.exitCode) === null ? null : Math.trunc(numberOrNull(item.exitCode)),
|
|
90
|
+
exitSignal: numberOrNull(item.exitSignal) === null ? null : Math.trunc(numberOrNull(item.exitSignal)),
|
|
91
|
+
state: stateOf(item.state),
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Count back from the newest record so a store stays useful after truncation. */
|
|
99
|
+
function sortNewestFirst(left, right) {
|
|
100
|
+
const finished = (record) => record.exitedAt ?? record.startedAt ?? 0;
|
|
101
|
+
return finished(right) - finished(left);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Bound one record before it is written: long commands must not bloat the file. */
|
|
105
|
+
function normalizeRecord(input) {
|
|
106
|
+
return recordFromStored(input);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function emptyStore() {
|
|
110
|
+
return { version: 1, jobs: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Move a corrupt store out of the way instead of losing the user's job history
|
|
115
|
+
* to the next write, then start from scratch. Never throws: a failed rename (a
|
|
116
|
+
* read-only home, say) leaves the next load to fail the same harmless way.
|
|
117
|
+
*/
|
|
118
|
+
function quarantineCorruptStore(file) {
|
|
119
|
+
try {
|
|
120
|
+
const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
|
|
121
|
+
renameSync(file, `${file}.corrupt-${stamp}`);
|
|
122
|
+
} catch {
|
|
123
|
+
try {
|
|
124
|
+
unlinkSync(file);
|
|
125
|
+
} catch {
|
|
126
|
+
/* the next successful save replaces it */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read every record; a missing, unreadable or corrupt file yields `[]`. */
|
|
132
|
+
export function loadJobRecords() {
|
|
133
|
+
const file = jobsFile();
|
|
134
|
+
let raw;
|
|
135
|
+
try {
|
|
136
|
+
raw = readFileSync(file, 'utf8');
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(raw);
|
|
143
|
+
} catch {
|
|
144
|
+
quarantineCorruptStore(file);
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
const items = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
148
|
+
const byId = new Map();
|
|
149
|
+
for (const item of items) {
|
|
150
|
+
const record = recordFromStored(item);
|
|
151
|
+
if (!record) continue;
|
|
152
|
+
byId.set(record.id, record);
|
|
153
|
+
}
|
|
154
|
+
return [...byId.values()].slice(0, MAX_JOB_RECORDS);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Atomically replace the store (temp file + rename, 0600 like the other stores). */
|
|
158
|
+
export function saveJobRecords(records) {
|
|
159
|
+
const normalized = [];
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
for (const item of [...(Array.isArray(records) ? records : [])].sort(sortNewestFirst)) {
|
|
162
|
+
const record = normalizeRecord(item);
|
|
163
|
+
if (!record || seen.has(record.id)) continue;
|
|
164
|
+
seen.add(record.id);
|
|
165
|
+
normalized.push(record);
|
|
166
|
+
}
|
|
167
|
+
const bounded = normalized.slice(0, MAX_JOB_RECORDS);
|
|
168
|
+
const file = jobsFile();
|
|
169
|
+
mkdirSync(storeDirectory(), { recursive: true });
|
|
170
|
+
const payload = `${JSON.stringify({ version: 1, jobs: bounded }, null, 2)}\n`;
|
|
171
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
172
|
+
writeFileSync(temp, payload, { encoding: 'utf8', mode: 0o600 });
|
|
173
|
+
try {
|
|
174
|
+
renameSync(temp, file);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// Windows can refuse renaming over a file another process holds open. Fall
|
|
177
|
+
// back to a direct write so the save still lands, then drop the temp file.
|
|
178
|
+
try {
|
|
179
|
+
unlinkSync(temp);
|
|
180
|
+
} catch {
|
|
181
|
+
/* best effort */
|
|
182
|
+
}
|
|
183
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'EPERM' && error?.code !== 'EACCES') throw error;
|
|
184
|
+
writeFileSync(file, payload, { encoding: 'utf8', mode: 0o600 });
|
|
185
|
+
}
|
|
186
|
+
return bounded;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Create or replace one record (identity is `id`). */
|
|
190
|
+
export function upsertJobRecord(record) {
|
|
191
|
+
const normalized = normalizeRecord(record);
|
|
192
|
+
if (!normalized) throw new Error('a job record needs at least id and host');
|
|
193
|
+
const records = loadJobRecords();
|
|
194
|
+
const index = records.findIndex((entry) => entry.id === normalized.id);
|
|
195
|
+
if (index >= 0) records[index] = normalized;
|
|
196
|
+
else records.unshift(normalized);
|
|
197
|
+
saveJobRecords(records);
|
|
198
|
+
return normalized;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Every stored record, optionally narrowed to one session. Newest first. */
|
|
202
|
+
export function listJobRecords({ sessionId = undefined } = {}) {
|
|
203
|
+
return loadJobRecords()
|
|
204
|
+
.filter((record) => sessionId === undefined || record.sessionId === sessionId)
|
|
205
|
+
.sort(sortNewestFirst);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Forget one record. Returns whether it existed. */
|
|
209
|
+
export function removeJobRecord(id) {
|
|
210
|
+
const records = loadJobRecords();
|
|
211
|
+
const next = records.filter((record) => record.id !== id);
|
|
212
|
+
if (next.length === records.length) return false;
|
|
213
|
+
saveJobRecords(next);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Bound the store: drop finished jobs older than `maxAgeMs` first, then the
|
|
219
|
+
* oldest finished jobs beyond `maxEntries`. Running records are the only truth
|
|
220
|
+
* about a live process and are kept; if they alone exceed the hard bound, the
|
|
221
|
+
* oldest are dropped last so the file still cannot grow without limit.
|
|
222
|
+
*
|
|
223
|
+
* Returns the removed ids (deterministic order).
|
|
224
|
+
*/
|
|
225
|
+
export function pruneJobRecords({ maxAgeMs = MAX_JOB_AGE_MS, maxEntries = MAX_JOB_RECORDS } = {}) {
|
|
226
|
+
const records = loadJobRecords();
|
|
227
|
+
const age = Number.isFinite(Number(maxAgeMs)) && Number(maxAgeMs) >= 0 ? Number(maxAgeMs) : MAX_JOB_AGE_MS;
|
|
228
|
+
const limit = Number.isFinite(Number(maxEntries))
|
|
229
|
+
? Math.max(0, Math.min(MAX_JOB_RECORDS, Math.floor(Number(maxEntries))))
|
|
230
|
+
: MAX_JOB_RECORDS;
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const finished = (record) => record.exitedAt ?? record.startedAt ?? 0;
|
|
233
|
+
const dropped = new Set();
|
|
234
|
+
for (const record of records) {
|
|
235
|
+
if (record.state === 'exited' && now - finished(record) >= age) dropped.add(record.id);
|
|
236
|
+
}
|
|
237
|
+
let kept = records.filter((record) => !dropped.has(record.id));
|
|
238
|
+
const newest = [...kept].sort(sortNewestFirst);
|
|
239
|
+
for (const record of newest.slice(limit)) {
|
|
240
|
+
if (record.state === 'exited') dropped.add(record.id);
|
|
241
|
+
}
|
|
242
|
+
kept = records.filter((record) => !dropped.has(record.id));
|
|
243
|
+
if (kept.length > MAX_JOB_RECORDS) {
|
|
244
|
+
const oldestFirst = [...kept].sort((left, right) => finished(left) - finished(right));
|
|
245
|
+
for (const state of ['exited', 'unknown', 'running']) {
|
|
246
|
+
for (const record of oldestFirst) {
|
|
247
|
+
if (kept.length <= MAX_JOB_RECORDS) break;
|
|
248
|
+
if (dropped.has(record.id) || record.state !== state) continue;
|
|
249
|
+
dropped.add(record.id);
|
|
250
|
+
kept = kept.filter((entry) => entry.id !== record.id);
|
|
251
|
+
}
|
|
252
|
+
if (kept.length <= MAX_JOB_RECORDS) break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (dropped.size > 0) saveJobRecords(kept);
|
|
256
|
+
return { removed: dropped.size, ids: [...dropped] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* File-backed owner of the job registry. All state lives in the JSON file, so a
|
|
261
|
+
* second instance (or a probe) sees exactly what the plugin sees.
|
|
262
|
+
*/
|
|
263
|
+
export class JobStore {
|
|
264
|
+
/** Absolute store path, exposed for diagnostics and probes. */
|
|
265
|
+
get file() {
|
|
266
|
+
return jobsFile();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
load() {
|
|
270
|
+
return loadJobRecords();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
list(query = {}) {
|
|
274
|
+
return listJobRecords(query);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
save(records) {
|
|
278
|
+
return saveJobRecords(records);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
upsert(record) {
|
|
282
|
+
return upsertJobRecord(record);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
remove(id) {
|
|
286
|
+
return removeJobRecord(id);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
prune(options = {}) {
|
|
290
|
+
return pruneJobRecords(options);
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/observed-store.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
// Persistent record of "this session has seen this remote file at version X".
|
|
2
2
|
//
|
|
3
|
-
// The harness read-before-edit guard keeps its state in a WeakMap
|
|
4
|
-
// live Session object, so a resumed/compacted agent loses it and
|
|
5
|
-
// forced to re-read huge files. We keep an independent record on
|
|
6
|
-
// before a built-in edit/write, re-publish the observation when
|
|
7
|
-
// still at the recorded version. Safety is preserved: if the file
|
|
8
|
-
// the read, nothing is repaired and the guard still refuses.
|
|
3
|
+
// The harness read-before-edit guard keeps its state in a per-plugin WeakMap
|
|
4
|
+
// keyed by the live Session object, so a resumed/compacted agent loses it and
|
|
5
|
+
// the model is forced to re-read huge files. We keep an independent record on
|
|
6
|
+
// disk and, just before a built-in edit/write, re-publish the observation when
|
|
7
|
+
// the file is still at the recorded version. Safety is preserved: if the file
|
|
8
|
+
// changed since the read, nothing is repaired and the guard still refuses.
|
|
9
|
+
//
|
|
10
|
+
// 0.20: keys are canonical. One remote file has many spellings — a
|
|
11
|
+
// `presence://host/path` URL, a local mount path, a relative path inside the
|
|
12
|
+
// workspace, or a remote absolute path — and the guard only matches when every
|
|
13
|
+
// spelling becomes the same `{ host, remotePath }` identity. The canonical key
|
|
14
|
+
// is exactly the `presence://` target key the fs provider hands the policy, so
|
|
15
|
+
// an observation recorded through presence_read repairs a built-in edit of the
|
|
16
|
+
// same file. Local (non-remote) observations keep their provider target key.
|
|
9
17
|
|
|
10
18
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
11
19
|
import { homedir } from 'node:os';
|
|
12
20
|
import { join } from 'node:path';
|
|
21
|
+
import { normalizeRemotePath, parseMountPath, parsePresenceUrl } from './mounts.js';
|
|
13
22
|
|
|
14
23
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
15
24
|
const MAX_ENTRIES_PER_SESSION = 500;
|
|
25
|
+
const STORE_VERSION = 2;
|
|
16
26
|
|
|
17
27
|
function storePath() {
|
|
18
28
|
const home = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
@@ -20,13 +30,15 @@ function storePath() {
|
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
function emptyStore() {
|
|
23
|
-
return { version:
|
|
33
|
+
return { version: STORE_VERSION, sessions: {} };
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
function load() {
|
|
27
37
|
try {
|
|
28
38
|
const parsed = JSON.parse(readFileSync(storePath(), 'utf8'));
|
|
29
|
-
if (parsed && typeof parsed === 'object' && parsed.version === 1
|
|
39
|
+
if (parsed && typeof parsed === 'object' && parsed.sessions && (parsed.version === 1 || parsed.version === STORE_VERSION)) {
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
30
42
|
} catch {
|
|
31
43
|
/* missing or corrupt: start clean */
|
|
32
44
|
}
|
|
@@ -38,7 +50,7 @@ function save(store) {
|
|
|
38
50
|
const target = storePath();
|
|
39
51
|
mkdirSync(join(target, '..'), { recursive: true });
|
|
40
52
|
const temporary = `${target}.tmp-${process.pid}`;
|
|
41
|
-
writeFileSync(temporary, JSON.stringify(store));
|
|
53
|
+
writeFileSync(temporary, JSON.stringify({ ...store, version: STORE_VERSION }));
|
|
42
54
|
renameSync(temporary, target);
|
|
43
55
|
} catch {
|
|
44
56
|
/* the record is an optimization; never fail a tool because of it */
|
|
@@ -57,22 +69,127 @@ function prune(store) {
|
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
/** Canonical `presence://host/absolute-path` key for one remote identity. */
|
|
73
|
+
function makeCanonical(host, remotePath) {
|
|
74
|
+
const alias = String(host ?? '').trim();
|
|
75
|
+
if (!alias) return null;
|
|
76
|
+
const path = normalizeRemotePath(remotePath);
|
|
77
|
+
return { host: alias, remotePath: path, key: `presence://${alias}${path}` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve any spelling of a remote path to its canonical `{ host, remotePath,
|
|
82
|
+
* key }`, or null when the input is not a remote path.
|
|
83
|
+
*
|
|
84
|
+
* Accepted:
|
|
85
|
+
* - `presence://host/abs/path` (scheme is case-insensitive)
|
|
86
|
+
* - a local mount path `<mountsRoot>/host/abs/path`
|
|
87
|
+
* - an absolute POSIX path when `host` is known (explicit host / mounted cwd)
|
|
88
|
+
* - a relative path joined to the mounted cwd's remote folder
|
|
89
|
+
* - an object with `targetKey`, or with `host` plus `path`/`remotePath`
|
|
90
|
+
*
|
|
91
|
+
* Local Windows paths and anything that cannot name a remote file stay null.
|
|
92
|
+
*/
|
|
93
|
+
export function canonicalObservation(input, { cwd = null, host = null } = {}) {
|
|
94
|
+
const explicitHost = typeof host === 'string' && host.trim() ? host.trim() : null;
|
|
95
|
+
if (input && typeof input === 'object') {
|
|
96
|
+
const targetKey = typeof input.targetKey === 'string' ? input.targetKey : null;
|
|
97
|
+
const directHost = typeof input.host === 'string' && input.host.trim() ? input.host.trim() : null;
|
|
98
|
+
const directPath =
|
|
99
|
+
typeof input.remotePath === 'string' ? input.remotePath : typeof input.path === 'string' ? input.path : null;
|
|
100
|
+
if (targetKey) {
|
|
101
|
+
const viaTarget = canonicalObservation(targetKey, { cwd, host: directHost ?? explicitHost });
|
|
102
|
+
if (viaTarget) return viaTarget;
|
|
103
|
+
}
|
|
104
|
+
if (directHost && directPath) return makeCanonical(directHost, directPath);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const value = String(input ?? '').trim();
|
|
109
|
+
if (!value) return null;
|
|
110
|
+
|
|
111
|
+
// One spelling of `presence://` may have a different scheme case.
|
|
112
|
+
let viaUrl = parsePresenceUrl(value);
|
|
113
|
+
if (!viaUrl && /^presence:/i.test(value)) viaUrl = parsePresenceUrl(value.replace(/^presence:/i, 'presence:'));
|
|
114
|
+
if (viaUrl) return makeCanonical(viaUrl.host, viaUrl.path);
|
|
115
|
+
|
|
116
|
+
const viaMount = parseMountPath(value);
|
|
117
|
+
if (viaMount) return makeCanonical(viaMount.host, viaMount.path);
|
|
118
|
+
|
|
119
|
+
// A Windows drive/UNC path is local even when a session is remote.
|
|
120
|
+
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\')) return null;
|
|
121
|
+
|
|
122
|
+
const mounted = cwd ? parseMountPath(cwd) : null;
|
|
123
|
+
if (value.startsWith('/')) {
|
|
124
|
+
const targetHost = explicitHost ?? mounted?.host ?? null;
|
|
125
|
+
if (!targetHost) return null;
|
|
126
|
+
return makeCanonical(targetHost, value);
|
|
127
|
+
}
|
|
128
|
+
if (mounted && (!explicitHost || explicitHost === mounted.host)) {
|
|
129
|
+
if (value === '.' || value === './') return makeCanonical(mounted.host, mounted.path);
|
|
130
|
+
return makeCanonical(mounted.host, `${mounted.path}/${value.replace(/\\/gu, '/')}`);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The canonical string key for one remote identity, or null. */
|
|
136
|
+
export function canonicalObservationKey(input, options = {}) {
|
|
137
|
+
return canonicalObservation(input, options)?.key ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The stored entry for `target` in this session, or null when unseen/stale. */
|
|
141
|
+
function lookupEntry(sessionId, target, options = {}) {
|
|
142
|
+
if (!sessionId || !target) return null;
|
|
143
|
+
const store = load();
|
|
144
|
+
const entries = store.sessions[sessionId];
|
|
145
|
+
if (!entries) return null;
|
|
146
|
+
const keys = [];
|
|
147
|
+
const canonical = canonicalObservation(target, options);
|
|
148
|
+
if (canonical) keys.push(canonical.key);
|
|
149
|
+
// Keep the legacy spelling working: 0.19 wrote the raw provider target key.
|
|
150
|
+
if (typeof target === 'string') keys.push(target);
|
|
151
|
+
else if (target && typeof target.targetKey === 'string') keys.push(target.targetKey);
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const entry = entries[key];
|
|
154
|
+
if (!entry) continue;
|
|
155
|
+
if (Date.now() - entry.at > MAX_AGE_MS) return null;
|
|
156
|
+
return entry;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remember that `sessionId` saw `target` at `version`. `target` accepts every
|
|
163
|
+
* spelling canonicalObservation does; `options.cwd`/`options.host` supply the
|
|
164
|
+
* session context when the spelling is relative or bare-absolute.
|
|
165
|
+
*/
|
|
166
|
+
export function recordObservation(sessionId, target, version, options = {}) {
|
|
167
|
+
if (!sessionId || !target) return;
|
|
168
|
+
const canonical = canonicalObservation(target, options);
|
|
169
|
+
const fallback = typeof target === 'string' ? target : typeof target?.targetKey === 'string' ? target.targetKey : null;
|
|
170
|
+
const key = canonical?.key ?? fallback;
|
|
171
|
+
if (!key) return;
|
|
63
172
|
const store = load();
|
|
64
173
|
store.sessions[sessionId] ??= {};
|
|
65
|
-
store.sessions[sessionId][
|
|
174
|
+
store.sessions[sessionId][key] = {
|
|
175
|
+
version,
|
|
176
|
+
at: Date.now(),
|
|
177
|
+
...(canonical ? { host: canonical.host, remotePath: canonical.remotePath } : {}),
|
|
178
|
+
};
|
|
66
179
|
prune(store);
|
|
67
180
|
save(store);
|
|
68
181
|
}
|
|
69
182
|
|
|
70
|
-
/** Version this session recorded for `
|
|
71
|
-
export function lookupObservation(sessionId,
|
|
72
|
-
|
|
73
|
-
const store = load();
|
|
74
|
-
const entry = store.sessions[sessionId]?.[targetKey];
|
|
183
|
+
/** Version this session recorded for `target`, or null when unseen/stale. */
|
|
184
|
+
export function lookupObservation(sessionId, target, options = {}) {
|
|
185
|
+
const entry = lookupEntry(sessionId, target, options);
|
|
75
186
|
if (!entry) return null;
|
|
76
|
-
if (Date.now() - entry.at > MAX_AGE_MS) return null;
|
|
77
187
|
return entry.version ?? null;
|
|
78
188
|
}
|
|
189
|
+
|
|
190
|
+
/** Host/path of a stored observation (diagnostics and probes), or null. */
|
|
191
|
+
export function describeObservation(sessionId, target, options = {}) {
|
|
192
|
+
const entry = lookupEntry(sessionId, target, options);
|
|
193
|
+
if (!entry) return null;
|
|
194
|
+
return { version: entry.version ?? null, host: entry.host ?? null, remotePath: entry.remotePath ?? null, at: entry.at ?? null };
|
|
195
|
+
}
|