dsh-archived 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -80,7 +80,15 @@ window.__ModuleLoader__.load({
80
80
  document.head.appendChild(tag);
81
81
  }
82
82
 
83
- /** Same-origin call to this plugin's host routes; never throws on a JSON error body. */
83
+ /**
84
+ * Same-origin call to this plugin's host routes.
85
+ *
86
+ * A JSON error body is returned for the caller to localize, but a transport
87
+ * failure must reject: a 403 (refused by the trust gate) or a 500 used to
88
+ * resolve into whatever JSON it carried, and every caller then read a field
89
+ * that wasn't there — the panel ended up showing a generic failure that hid
90
+ * the real reason.
91
+ */
84
92
  function request(path, options) {
85
93
  var init = {
86
94
  method: options && options.method ? options.method : "GET",
@@ -92,9 +100,22 @@ window.__ModuleLoader__.load({
92
100
  init.body = JSON.stringify(options.body);
93
101
  }
94
102
  return fetch(path, init).then(function (response) {
95
- return response.json().catch(function () {
96
- throw new Error("HTTP " + response.status);
97
- });
103
+ return response
104
+ .json()
105
+ .catch(function () {
106
+ return { error: "internal" };
107
+ })
108
+ .then(function (payload) {
109
+ if (!response.ok) {
110
+ // Localizing needs the panel's `t`, so carry the host's error code
111
+ // and status out and let the caller translate them.
112
+ var error = new Error("HTTP " + response.status);
113
+ error.code = payload && payload.error ? String(payload.error) : "internal";
114
+ error.status = response.status;
115
+ throw error;
116
+ }
117
+ return payload;
118
+ });
98
119
  });
99
120
  }
100
121
 
@@ -276,7 +297,10 @@ window.__ModuleLoader__.load({
276
297
  return payload;
277
298
  })
278
299
  .catch(function (error) {
279
- setNotice({ kind: "error", text: String(error && error.message ? error.message : error) });
300
+ // A transport failure carries the host's error code; a payload-level
301
+ // business error already carries a localized message.
302
+ var text = error && error.code ? describeError(error.code) : String(error && error.message ? error.message : error);
303
+ setNotice({ kind: "error", text: text });
280
304
  })
281
305
  .then(function () {
282
306
  setBusy(null);
@@ -297,24 +321,39 @@ window.__ModuleLoader__.load({
297
321
  });
298
322
  }
299
323
 
324
+ /**
325
+ * Report one batch reply. The host's `delete-all` answers with a per-row
326
+ * `results` array; reading it unguarded turned any other reply into a
327
+ * TypeError that replaced the real reason on screen.
328
+ */
329
+ function reportBatch(payload) {
330
+ var results = payload && Array.isArray(payload.results) ? payload.results : [];
331
+ if (results.length === 0) {
332
+ setNotice({ kind: "error", text: t("error.generic") });
333
+ return;
334
+ }
335
+ var ok = 0;
336
+ var bytes = 0;
337
+ for (var i = 0; i < results.length; i += 1) {
338
+ if (results[i] && results[i].ok === true) {
339
+ ok += 1;
340
+ if (results[i].removed) bytes += results[i].removed.bytes || 0;
341
+ }
342
+ }
343
+ setNotice(
344
+ ok === results.length
345
+ ? { kind: "ok", text: t("deletedMany", { n: ok, size: formatBytes(bytes) }) }
346
+ : { kind: "error", text: t("deletedPartial", { ok: ok, failed: results.length - ok }) },
347
+ );
348
+ }
349
+
300
350
  function deleteSelected(mode) {
301
351
  var ids = selectedIds;
302
352
  if (ids.length === 0) return;
303
353
  setBusy("__selected__");
304
354
  run(DELETE_ALL_PATH, { ids: ids, mode: mode }, function (payload) {
305
- var ok = payload.results.filter(function (result) {
306
- return result.ok === true;
307
- }).length;
308
- var bytes = 0;
309
- for (var i = 0; i < payload.results.length; i += 1) {
310
- if (payload.results[i].ok && payload.results[i].removed) bytes += payload.results[i].removed.bytes || 0;
311
- }
312
355
  setSelected({});
313
- setNotice(
314
- ok === payload.results.length
315
- ? { kind: "ok", text: t("deletedMany", { n: ok, size: formatBytes(bytes) }) }
316
- : { kind: "error", text: t("deletedPartial", { ok: ok, failed: payload.results.length - ok }) },
317
- );
356
+ reportBatch(payload);
318
357
  });
319
358
  }
320
359
 
@@ -323,20 +362,7 @@ window.__ModuleLoader__.load({
323
362
  return row.id;
324
363
  });
325
364
  setBusy("__all__");
326
- run(DELETE_ALL_PATH, { ids: ids, mode: mode }, function (payload) {
327
- var ok = payload.results.filter(function (result) {
328
- return result.ok === true;
329
- }).length;
330
- var bytes = 0;
331
- for (var i = 0; i < payload.results.length; i += 1) {
332
- if (payload.results[i].ok && payload.results[i].removed) bytes += payload.results[i].removed.bytes || 0;
333
- }
334
- setNotice(
335
- ok === payload.results.length
336
- ? { kind: "ok", text: t("deletedMany", { n: ok, size: formatBytes(bytes) }) }
337
- : { kind: "error", text: t("deletedPartial", { ok: ok, failed: payload.results.length - ok }) },
338
- );
339
- });
365
+ run(DELETE_ALL_PATH, { ids: ids, mode: mode }, reportBatch);
340
366
  }
341
367
 
342
368
  function restoreRow(entry) {
package/lib/host/paths.js CHANGED
@@ -18,8 +18,87 @@ import { readdir, stat } from "node:fs/promises";
18
18
  import { homedir } from "node:os";
19
19
  import { join, resolve } from "node:path";
20
20
 
21
- /** Session ids this plugin is willing to touch: the canonical `session-<uuid>`. */
22
- export const SESSION_ID = /^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+ /**
22
+ * Ids the *store* and the *host API* accept. A session is `session-<uuid>`, but
23
+ * a session whose header carries no prefix at all is a bare uuid (imported
24
+ * transcripts and delegate imports both produce those).
25
+ */
26
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
27
+ const IMPORT_PREFIXES = ["import-session-", "import-"];
28
+ const CANONICAL_PREFIX = "session-";
29
+ export const HOST_ID = new RegExp(`^(?:${CANONICAL_PREFIX})?${UUID}$`, "i");
30
+
31
+ /**
32
+ * Ids an archived *row* may be filed under.
33
+ *
34
+ * DSH's own sessions are `session-<uuid>`, but the import feature namespaces the
35
+ * id it files in the archive index: `import-session-<uuid>` for an imported
36
+ * transcript and `import-<uuid>` for a delegate-imported session. Every one of
37
+ * those is a real archived row the panel draws, so all of them must be accepted.
38
+ * Refusing the import namespaces was a silent, total failure: the rows rendered
39
+ * fine and every delete came back `invalid-session-id`.
40
+ *
41
+ * The path-safety argument is unchanged — a bare uuid is the only shape any form
42
+ * may carry, so no form can introduce a separator, a `..`, or a NUL.
43
+ */
44
+ export const SESSION_ID = new RegExp(`^(?:import-session-|import-|${CANONICAL_PREFIX})?${UUID}$`, "i");
45
+
46
+ /** The raw id a registry entry names, with its import namespace peeled off. */
47
+ export function canonicalSessionId(raw) {
48
+ const text = String(raw);
49
+ for (const prefix of IMPORT_PREFIXES) {
50
+ if (text.startsWith(prefix)) return text.slice(prefix.length);
51
+ }
52
+ return text;
53
+ }
54
+
55
+ /**
56
+ * Ordered ids the *filesystem* may know one archived row by.
57
+ *
58
+ * The archive index is namespaced; the session store is not. `import-<uuid>` and
59
+ * `import-session-<uuid>` both name a session whose directory (and whose log
60
+ * header `id`) is the bare uuid or `session-<uuid>`. `locateArtifacts` only ever
61
+ * accepts a directory whose decoded name is one of these, so a namespace
62
+ * collision cannot make the plugin delete a different session's log.
63
+ */
64
+ export function artifactIdCandidates(raw) {
65
+ const text = String(raw);
66
+ const out = [text];
67
+ const add = (value) => {
68
+ if (value.length > 0 && !out.includes(value)) out.push(value);
69
+ };
70
+ // The import namespace wraps the store shape, so peeling it off is the first
71
+ // and usually only step. The other store form is added as a fallback, because
72
+ // each import form has been seen wrapping each store shape. Peeling `import-`
73
+ // off `import-session-<uuid>` would leave `session-session-<uuid>`, a name
74
+ // nothing on disk carries.
75
+ for (const prefix of IMPORT_PREFIXES) {
76
+ if (!text.startsWith(prefix)) continue;
77
+ const rest = text.slice(prefix.length);
78
+ add(rest);
79
+ add(CANONICAL_PREFIX + rest);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * Inverse of DSH's `encodeSegment`: safe units stay literal and every other code
86
+ * unit is `~XXXX`. Lets a directory name be compared against the candidate ids
87
+ * instead of guessing at string prefixes.
88
+ */
89
+ export function decodeSegment(name) {
90
+ let out = "";
91
+ for (let i = 0; i < name.length; i += 1) {
92
+ const ch = name[i];
93
+ if (ch !== "~") {
94
+ out += ch;
95
+ continue;
96
+ }
97
+ out += String.fromCharCode(parseInt(name.slice(i + 1, i + 5), 16));
98
+ i += 4;
99
+ }
100
+ return out;
101
+ }
23
102
 
24
103
  export function dshHome() {
25
104
  const configured = process.env.DSH_HOME;
@@ -78,8 +157,20 @@ export function quarantineMetaFile(sessionId) {
78
157
  return join(quarantineEntry(sessionId), "meta.json");
79
158
  }
80
159
 
81
- /** Every on-disk artifact directory of one session (normally zero or one). */
160
+ /**
161
+ * Every on-disk artifact directory of one session (normally zero or one).
162
+ *
163
+ * The directory is named after the id inside the session log's header, which is
164
+ * NOT always the id the archive index files the row under: an imported row is
165
+ * indexed as `import-session-<uuid>` / `import-<uuid>` while its directory is
166
+ * `session-<uuid>` or the bare uuid. Looking up the raw registry id therefore
167
+ * found nothing, so a delete freed the projection cache and the index entry and
168
+ * silently left the session log on disk — the exact "fake success" this plugin
169
+ * exists to prevent. Match on the decoded directory name instead.
170
+ */
82
171
  export async function locateArtifacts(sessionId) {
172
+ const candidates = artifactIdCandidates(sessionId);
173
+ const wanted = new Set(candidates.map((id) => id.toLowerCase()));
83
174
  const found = [];
84
175
  let workspaces;
85
176
  try {
@@ -89,8 +180,26 @@ export async function locateArtifacts(sessionId) {
89
180
  }
90
181
  for (const workspace of workspaces) {
91
182
  if (!workspace.isDirectory()) continue;
92
- const candidate = join(sessionsRoot(), workspace.name, sessionId);
93
- if (existsSync(candidate)) found.push(candidate);
183
+ const bucket = join(sessionsRoot(), workspace.name);
184
+ // The pre-encoded name is the common case; only fall back to decoding the
185
+ // bucket's entries when it is absent (an id that needed `encodeSegment`).
186
+ const direct = candidates.map((id) => join(bucket, id)).find((path) => existsSync(path));
187
+ if (direct !== undefined) {
188
+ found.push(direct);
189
+ continue;
190
+ }
191
+ let entries;
192
+ try {
193
+ entries = await readdir(bucket, { withFileTypes: true });
194
+ } catch {
195
+ continue;
196
+ }
197
+ for (const entry of entries) {
198
+ if (!entry.isDirectory()) continue;
199
+ if (!wanted.has(decodeSegment(entry.name).toLowerCase())) continue;
200
+ found.push(join(bucket, entry.name));
201
+ break;
202
+ }
94
203
  }
95
204
  return found;
96
205
  }
@@ -14,6 +14,7 @@ import { constants } from "node:fs";
14
14
  import { access, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
15
15
  import { dirname, join } from "node:path";
16
16
  import {
17
+ canonicalSessionId,
17
18
  directorySize,
18
19
  projectionCacheFile,
19
20
  quarantineArtifactDir,
@@ -133,8 +134,13 @@ export async function restoreSession(ctx, sessionId, entry) {
133
134
  const artifact = quarantineArtifactDir(sessionId);
134
135
  const cache = quarantineCacheFile(sessionId);
135
136
  const restored = { directories: 0, cache: false, rearchived: false };
137
+ // The registry id may be namespaced (`import-…`), while the log directory and
138
+ // the projection-cache document are named after the raw session id. Restoring
139
+ // under the registry id would put the directory back under a name nothing
140
+ // looks for again.
141
+ const rawId = canonicalSessionId(sessionId);
136
142
 
137
- const target = entry.workspaceDir !== null ? join(entry.workspaceDir, sessionId) : null;
143
+ const target = entry.workspaceDir !== null ? join(entry.workspaceDir, rawId) : null;
138
144
  if (await exists(artifact)) {
139
145
  if (target === null) throw new Error("quarantine metadata has no workspaceDir");
140
146
  await mkdir(dirname(target), { recursive: true });
@@ -142,7 +148,7 @@ export async function restoreSession(ctx, sessionId, entry) {
142
148
  restored.directories = 1;
143
149
  }
144
150
  if (await exists(cache)) {
145
- await movePath(cache, projectionCacheFile(sessionId));
151
+ await movePath(cache, projectionCacheFile(rawId));
146
152
  restored.cache = true;
147
153
  }
148
154
  await rm(root, { recursive: true, force: true });
package/lib/index.js CHANGED
@@ -31,6 +31,8 @@ import { dirname } from "node:path";
31
31
 
32
32
  import {
33
33
  SESSION_ID,
34
+ artifactIdCandidates,
35
+ canonicalSessionId,
34
36
  directorySize,
35
37
  dshHome,
36
38
  legacyAggregateFile,
@@ -130,6 +132,33 @@ function archivedIds(ctx) {
130
132
  return Array.isArray(ids) ? [...ids] : [];
131
133
  }
132
134
 
135
+ /**
136
+ * Ids other than the archived row that could name the *live agent* of the same
137
+ * session. An imported row is indexed under a namespaced id
138
+ * (`import-session-<uuid>` / `import-<uuid>`) while the agent table, the session
139
+ * store and the on-disk directory all know the bare uuid, so a running-turn
140
+ * check done only against the archived id would miss a live agent and delete a
141
+ * session out from under it.
142
+ */
143
+ function agentIdCandidates(sessionId) {
144
+ return artifactIdCandidates(sessionId).filter((id) => id !== sessionId);
145
+ }
146
+
147
+ /**
148
+ * Is any candidate id for this row a live agent running a turn? The archived id
149
+ * is checked by the caller (it is the form the host may broadcast), so only the
150
+ * remaining candidates are consulted here.
151
+ */
152
+ function runningAlias(ctx, sessionId) {
153
+ return agentIdCandidates(sessionId).find((id) => isRunning(ctx, id)) ?? null;
154
+ }
155
+
156
+ /** The raw ids a delete had to look under, for an operator-facing log line. */
157
+ function artifactHints(sessionId) {
158
+ const hints = artifactIdCandidates(sessionId).filter((id) => id !== sessionId);
159
+ return hints.length > 0 ? `, looked up as ${hints.join(" | ")}` : "";
160
+ }
161
+
133
162
  /**
134
163
  * Whether this host still offers what a destructive route needs. A host whose
135
164
  * registry lost `unarchiveSession` cannot clear the archive index, and a delete
@@ -160,9 +189,10 @@ async function stateOf(ctx) {
160
189
  const dirs = await locateArtifacts(id);
161
190
  let bytes = 0;
162
191
  for (const dir of dirs) bytes += await directorySize(dir);
163
- const cacheFile = projectionCacheFile(id);
192
+ const rawId = canonicalSessionId(id);
193
+ const cacheFile = projectionCacheFile(rawId);
164
194
  const cache = existsSync(cacheFile);
165
- const document = cache ? await readCacheDocument(id) : null;
195
+ const document = cache ? await readCacheDocument(rawId) : null;
166
196
  items.push({
167
197
  id,
168
198
  archived: true,
@@ -225,7 +255,7 @@ async function sweepResidue(ctx, sessionId) {
225
255
  if (archivedIds(ctx).includes(sessionId)) return;
226
256
  if (isRunning(ctx, sessionId)) return;
227
257
  if ((await locateArtifacts(sessionId)).length > 0) return;
228
- const cacheFile = projectionCacheFile(sessionId);
258
+ const cacheFile = projectionCacheFile(canonicalSessionId(sessionId));
229
259
  if (existsSync(cacheFile)) await rm(cacheFile, { force: true });
230
260
  }
231
261
 
@@ -249,12 +279,17 @@ async function deleteOne(ctx, sessionId, mode) {
249
279
  if (!hostSupportsDelete(ctx)) return refuse("no-registry", { sessionId });
250
280
  if (!archivedIds(ctx).includes(sessionId)) return refuse("not-archived", { sessionId });
251
281
  if (isRunning(ctx, sessionId)) return refuse("session-running", { sessionId });
282
+ const alias = runningAlias(ctx, sessionId);
283
+ if (alias !== null) return refuse("session-running", { sessionId, runningAs: alias });
252
284
  const descendants = runningDescendants(ctx, sessionId);
253
285
  if (descendants.length > 0) return refuse("subagent-running", { sessionId, descendants });
254
286
 
255
287
  return withLock(sessionId, async () => {
288
+ // The archive index is namespaced; the log directory, the projection cache
289
+ // document and the host's own session store all use the raw id.
290
+ const rawId = canonicalSessionId(sessionId);
256
291
  const dirs = await locateArtifacts(sessionId);
257
- const cacheFile = projectionCacheFile(sessionId);
292
+ const cacheFile = projectionCacheFile(rawId);
258
293
  let bytes = 0;
259
294
  for (const dir of dirs) bytes += await directorySize(dir);
260
295
 
@@ -273,7 +308,7 @@ async function deleteOne(ctx, sessionId, mode) {
273
308
  // and that the panel would not even show.
274
309
  removed = { directories: 0, bytes: 0, cache: false, parked: false };
275
310
  } else {
276
- const document = existsSync(cacheFile) ? await readCacheDocument(sessionId) : null;
311
+ const document = existsSync(cacheFile) ? await readCacheDocument(rawId) : null;
277
312
  removed = await quarantineSession(sessionId, {
278
313
  dirs,
279
314
  cacheFile,
@@ -290,7 +325,7 @@ async function deleteOne(ctx, sessionId, mode) {
290
325
  await registryOf(ctx).unarchiveSession(sessionId);
291
326
  scheduleSweep(ctx, sessionId);
292
327
  ctx.logger?.info?.(
293
- `[dsh-archived] delete ${sessionId} (${mode}): ${removed.directories} dir(s), ${removed.bytes} bytes`,
328
+ `[dsh-archived] delete ${sessionId} (${mode}): ${removed.directories} dir(s), ${removed.bytes} bytes${artifactHints(sessionId)}`,
294
329
  );
295
330
  return { ok: true, sessionId, mode, removed, archivedSessionIds: archivedIds(ctx) };
296
331
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-archived",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Archived-sessions page for DeepSeek Harness: per-row and batch deletion that clears the session log, the projection cache and the archive index together, with a 30-day recycle bin, subagent-aware refusals and a per-row disk footprint.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",