@dennisrongo/dsh-todo 0.4.1 → 0.5.0
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 +46 -0
- package/lib/bin.js +2 -0
- package/lib/cli.js +2 -0
- package/lib/client.js +152 -10
- package/lib/index.js +412 -8
- package/lib/scan.js +276 -0
- package/lib/suggest.js +93 -0
- package/lib/typert.host.js +58 -1
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -48,8 +48,8 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
|
|
|
48
48
|
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
|
|
49
49
|
|
|
50
50
|
// src/index.ts
|
|
51
|
-
import { readFileSync, renameSync, existsSync } from "node:fs";
|
|
52
|
-
import { isAbsolute, join as
|
|
51
|
+
import { readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, existsSync, unlinkSync } from "node:fs";
|
|
52
|
+
import { isAbsolute, join as join3, resolve as resolve2 } from "node:path";
|
|
53
53
|
import { Service } from "@deepseek-ai/cordis";
|
|
54
54
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
55
55
|
import { z } from "zod";
|
|
@@ -92,6 +92,14 @@ var MAX_TEXT = 500;
|
|
|
92
92
|
var MAX_DESC = 5e3;
|
|
93
93
|
var MAX_LABEL = 60;
|
|
94
94
|
var MAX_ITEMS = 1e3;
|
|
95
|
+
var SUGGESTIONS_DIR = ".dsh";
|
|
96
|
+
var SUGGESTIONS_FILE = `${SUGGESTIONS_DIR}/suggestions.json`;
|
|
97
|
+
var SUGGESTIONS_FILE_RE = /^suggestions(-[a-z0-9]+)?\.json$/;
|
|
98
|
+
function suggestionsFileFor(runId) {
|
|
99
|
+
return `${SUGGESTIONS_DIR}/suggestions-${runId}.json`;
|
|
100
|
+
}
|
|
101
|
+
__name(suggestionsFileFor, "suggestionsFileFor");
|
|
102
|
+
var MAX_SUGGESTIONS = 12;
|
|
95
103
|
|
|
96
104
|
// src/db.ts
|
|
97
105
|
var DOT_DSH = ".dsh";
|
|
@@ -216,6 +224,337 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
216
224
|
}
|
|
217
225
|
__name(writeList, "writeList");
|
|
218
226
|
|
|
227
|
+
// src/scan.ts
|
|
228
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
229
|
+
import { join as join2, relative, sep } from "node:path";
|
|
230
|
+
var DIGEST_BYTE_CAP = 24e3;
|
|
231
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
232
|
+
".git",
|
|
233
|
+
".hg",
|
|
234
|
+
".svn",
|
|
235
|
+
"node_modules",
|
|
236
|
+
"bower_components",
|
|
237
|
+
"jspm_packages",
|
|
238
|
+
"lib",
|
|
239
|
+
"dist",
|
|
240
|
+
"build",
|
|
241
|
+
"out",
|
|
242
|
+
"coverage",
|
|
243
|
+
".next",
|
|
244
|
+
".nuxt",
|
|
245
|
+
".svelte-kit",
|
|
246
|
+
".output",
|
|
247
|
+
".parcel-cache",
|
|
248
|
+
".turbo",
|
|
249
|
+
".cache",
|
|
250
|
+
".venv",
|
|
251
|
+
"venv",
|
|
252
|
+
"__pycache__",
|
|
253
|
+
".tox",
|
|
254
|
+
".mypy_cache",
|
|
255
|
+
".pytest_cache",
|
|
256
|
+
"target",
|
|
257
|
+
"vendor",
|
|
258
|
+
"vendored",
|
|
259
|
+
"third_party",
|
|
260
|
+
"thirdparty",
|
|
261
|
+
"generated",
|
|
262
|
+
"__generated__",
|
|
263
|
+
"Pods",
|
|
264
|
+
"Carthage",
|
|
265
|
+
"DerivedData"
|
|
266
|
+
]);
|
|
267
|
+
var SOURCE_EXT = /* @__PURE__ */ new Set([
|
|
268
|
+
".ts",
|
|
269
|
+
".tsx",
|
|
270
|
+
".js",
|
|
271
|
+
".jsx",
|
|
272
|
+
".mjs",
|
|
273
|
+
".cjs",
|
|
274
|
+
".py",
|
|
275
|
+
".go",
|
|
276
|
+
".rs",
|
|
277
|
+
".java",
|
|
278
|
+
".rb",
|
|
279
|
+
".php",
|
|
280
|
+
".cs",
|
|
281
|
+
".swift",
|
|
282
|
+
".kt",
|
|
283
|
+
".scala",
|
|
284
|
+
".sh"
|
|
285
|
+
]);
|
|
286
|
+
var MAX_FILES_WALKED = 4e3;
|
|
287
|
+
var MAX_TREE_ENTRIES = 300;
|
|
288
|
+
var MAX_COMMENTS = 80;
|
|
289
|
+
var MAX_UNTESTED = 40;
|
|
290
|
+
var MAX_COMMENT_LINE = 160;
|
|
291
|
+
var README_BYTES = 4e3;
|
|
292
|
+
var MANIFEST_BYTES = 2e3;
|
|
293
|
+
var MAX_DEPTH = 8;
|
|
294
|
+
var SCAN_CEILING_FACTOR = 10;
|
|
295
|
+
var MAX_FILES_READ = 400;
|
|
296
|
+
var MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
297
|
+
function posix(path) {
|
|
298
|
+
return path.split(sep).join("/");
|
|
299
|
+
}
|
|
300
|
+
__name(posix, "posix");
|
|
301
|
+
function walk(root) {
|
|
302
|
+
const files = [];
|
|
303
|
+
let truncated = false;
|
|
304
|
+
const visit = /* @__PURE__ */ __name((dir, depth) => {
|
|
305
|
+
if (depth > MAX_DEPTH || files.length >= MAX_FILES_WALKED) {
|
|
306
|
+
truncated = true;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
let entries;
|
|
310
|
+
try {
|
|
311
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
312
|
+
} catch {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
if (files.length >= MAX_FILES_WALKED) {
|
|
317
|
+
truncated = true;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
322
|
+
visit(join2(dir, entry.name), depth + 1);
|
|
323
|
+
} else if (entry.isFile()) {
|
|
324
|
+
files.push(posix(relative(root, join2(dir, entry.name))));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}, "visit");
|
|
328
|
+
try {
|
|
329
|
+
if (!statSync(root).isDirectory()) return { files: [], truncated: false };
|
|
330
|
+
} catch {
|
|
331
|
+
return { files: [], truncated: false };
|
|
332
|
+
}
|
|
333
|
+
visit(root, 0);
|
|
334
|
+
return { files, truncated };
|
|
335
|
+
}
|
|
336
|
+
__name(walk, "walk");
|
|
337
|
+
function readText(path, limit = Number.MAX_SAFE_INTEGER) {
|
|
338
|
+
let raw;
|
|
339
|
+
try {
|
|
340
|
+
if (statSync(path).size > MAX_READ_BYTES) return "";
|
|
341
|
+
raw = readFileSync(path, "utf8");
|
|
342
|
+
} catch {
|
|
343
|
+
return "";
|
|
344
|
+
}
|
|
345
|
+
if (raw.includes("\0")) return "";
|
|
346
|
+
return raw.length > limit ? raw.slice(0, limit) : raw;
|
|
347
|
+
}
|
|
348
|
+
__name(readText, "readText");
|
|
349
|
+
function skippedForSize(path) {
|
|
350
|
+
try {
|
|
351
|
+
return statSync(path).size > MAX_READ_BYTES;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
__name(skippedForSize, "skippedForSize");
|
|
357
|
+
var COMMENT_RE = /(?:^|\s)(?:\/\/|#|\/\*|\*)\s*(TODO|FIXME|HACK)\b[:\s]?(.*)$/;
|
|
358
|
+
function collectComments(root, files) {
|
|
359
|
+
const ceiling = MAX_COMMENTS * SCAN_CEILING_FACTOR;
|
|
360
|
+
const kept = [];
|
|
361
|
+
let total = 0;
|
|
362
|
+
let read = 0;
|
|
363
|
+
let skipped = 0;
|
|
364
|
+
let bounded = false;
|
|
365
|
+
for (const rel of files) {
|
|
366
|
+
const dot = rel.lastIndexOf(".");
|
|
367
|
+
if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
|
|
368
|
+
if (total >= ceiling || read >= MAX_FILES_READ) {
|
|
369
|
+
bounded = true;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
const full = join2(root, rel);
|
|
373
|
+
if (skippedForSize(full)) {
|
|
374
|
+
skipped += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
read += 1;
|
|
378
|
+
const text = readText(full);
|
|
379
|
+
if (text === "") continue;
|
|
380
|
+
const lines = text.split(/\r?\n/);
|
|
381
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
382
|
+
const match = COMMENT_RE.exec(lines[i]);
|
|
383
|
+
if (match === null) continue;
|
|
384
|
+
total += 1;
|
|
385
|
+
if (kept.length >= MAX_COMMENTS) continue;
|
|
386
|
+
const body = match[2].trim().slice(0, MAX_COMMENT_LINE);
|
|
387
|
+
kept.push(`${rel}:${i + 1} ${match[1]} ${body}`.trimEnd());
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { kept, total, bounded, skippedForSize: skipped };
|
|
391
|
+
}
|
|
392
|
+
__name(collectComments, "collectComments");
|
|
393
|
+
function hasTest(base, testNames) {
|
|
394
|
+
return testNames.has(`${base}.test`) || testNames.has(`${base}.spec`) || testNames.has(`test_${base}`) || testNames.has(`${base}_test`) || testNames.has(base);
|
|
395
|
+
}
|
|
396
|
+
__name(hasTest, "hasTest");
|
|
397
|
+
function collectUntested(files) {
|
|
398
|
+
const testNames = /* @__PURE__ */ new Set();
|
|
399
|
+
for (const rel of files) {
|
|
400
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
401
|
+
const stem = name.replace(/\.[^.]+$/, "");
|
|
402
|
+
if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) {
|
|
403
|
+
testNames.add(stem);
|
|
404
|
+
testNames.add(stem.replace(/\.(test|spec)$/i, ""));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const ceiling = MAX_UNTESTED * SCAN_CEILING_FACTOR;
|
|
408
|
+
const kept = [];
|
|
409
|
+
let total = 0;
|
|
410
|
+
let bounded = false;
|
|
411
|
+
for (const rel of files) {
|
|
412
|
+
const dot = rel.lastIndexOf(".");
|
|
413
|
+
if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
|
|
414
|
+
if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) continue;
|
|
415
|
+
const stem = rel.slice(rel.lastIndexOf("/") + 1).replace(/\.[^.]+$/, "");
|
|
416
|
+
if (/^(index|main|types|constants)$/i.test(stem)) continue;
|
|
417
|
+
if (hasTest(stem, testNames)) continue;
|
|
418
|
+
if (total >= ceiling) {
|
|
419
|
+
bounded = true;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
total += 1;
|
|
423
|
+
if (kept.length < MAX_UNTESTED) kept.push(rel);
|
|
424
|
+
}
|
|
425
|
+
return { kept, total, bounded, skippedForSize: 0 };
|
|
426
|
+
}
|
|
427
|
+
__name(collectUntested, "collectUntested");
|
|
428
|
+
function sectionHeader(title, total, kept, options = {}) {
|
|
429
|
+
const bound = options.bounded === true ? "+" : "";
|
|
430
|
+
const skipped = options.skippedForSize ?? 0;
|
|
431
|
+
const note = skipped > 0 ? ` (${skipped} file(s) too large to read)` : "";
|
|
432
|
+
const counts = kept < total || options.bounded === true ? `(${total}${bound} found, showing ${kept})` : `(${total})`;
|
|
433
|
+
return `### ${title} ${counts}${note}`;
|
|
434
|
+
}
|
|
435
|
+
__name(sectionHeader, "sectionHeader");
|
|
436
|
+
function fileHeader(name, text, limit) {
|
|
437
|
+
if (text.length < limit) return `### ${name}`;
|
|
438
|
+
return `### ${name} (clipped to first ${Math.round(limit / 1e3)} KB)`;
|
|
439
|
+
}
|
|
440
|
+
__name(fileHeader, "fileHeader");
|
|
441
|
+
function assemble(sections, walkTruncated) {
|
|
442
|
+
const parts = walkTruncated ? sections.concat(
|
|
443
|
+
"[walk truncated \u2014 this workspace is deeper or larger than one scan walks; files below the depth or count limit were never examined]"
|
|
444
|
+
) : sections;
|
|
445
|
+
const joined = parts.join("\n\n");
|
|
446
|
+
if (joined.length <= DIGEST_BYTE_CAP) {
|
|
447
|
+
return { digest: joined, truncated: walkTruncated };
|
|
448
|
+
}
|
|
449
|
+
const marker = "\n\n[digest truncated \u2014 the workspace is larger than one scan can carry]";
|
|
450
|
+
return { digest: joined.slice(0, DIGEST_BYTE_CAP - marker.length) + marker, truncated: true };
|
|
451
|
+
}
|
|
452
|
+
__name(assemble, "assemble");
|
|
453
|
+
function buildDigest(root) {
|
|
454
|
+
const { files, truncated } = walk(root);
|
|
455
|
+
const sections = [];
|
|
456
|
+
let sectionTruncated = false;
|
|
457
|
+
const tree = files.slice(0, MAX_TREE_ENTRIES);
|
|
458
|
+
if (tree.length > 0) {
|
|
459
|
+
if (tree.length < files.length) sectionTruncated = true;
|
|
460
|
+
sections.push(`${sectionHeader("Files", files.length, tree.length)}
|
|
461
|
+
${tree.join("\n")}`);
|
|
462
|
+
}
|
|
463
|
+
const readmeName = files.find((f) => /^readme(\.md|\.txt)?$/i.test(f));
|
|
464
|
+
if (readmeName !== void 0) {
|
|
465
|
+
const raw = readText(join2(root, readmeName), README_BYTES);
|
|
466
|
+
const text = raw.trim();
|
|
467
|
+
if (text !== "") {
|
|
468
|
+
if (raw.length >= README_BYTES) sectionTruncated = true;
|
|
469
|
+
sections.push(`${fileHeader(readmeName, raw, README_BYTES)}
|
|
470
|
+
${text}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const manifest = files.find((f) => f === "package.json");
|
|
474
|
+
if (manifest !== void 0) {
|
|
475
|
+
const raw = readText(join2(root, manifest), MANIFEST_BYTES);
|
|
476
|
+
const text = raw.trim();
|
|
477
|
+
if (text !== "") {
|
|
478
|
+
if (raw.length >= MANIFEST_BYTES) sectionTruncated = true;
|
|
479
|
+
sections.push(`${fileHeader("package.json", raw, MANIFEST_BYTES)}
|
|
480
|
+
${text}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const comments = collectComments(root, files);
|
|
484
|
+
if (comments.kept.length > 0 || comments.skippedForSize > 0) {
|
|
485
|
+
if (comments.kept.length < comments.total || comments.bounded || comments.skippedForSize > 0) sectionTruncated = true;
|
|
486
|
+
sections.push(
|
|
487
|
+
sectionHeader(
|
|
488
|
+
"Unresolved comments (TODO/FIXME/HACK)",
|
|
489
|
+
comments.total,
|
|
490
|
+
comments.kept.length,
|
|
491
|
+
{ bounded: comments.bounded, skippedForSize: comments.skippedForSize }
|
|
492
|
+
) + (comments.kept.length > 0 ? "\n" + comments.kept.join("\n") : "")
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const untested = collectUntested(files);
|
|
496
|
+
if (untested.kept.length > 0) {
|
|
497
|
+
if (untested.kept.length < untested.total || untested.bounded) sectionTruncated = true;
|
|
498
|
+
sections.push(
|
|
499
|
+
sectionHeader(
|
|
500
|
+
"Untested modules (name-based hint, not a coverage run)",
|
|
501
|
+
untested.total,
|
|
502
|
+
untested.kept.length,
|
|
503
|
+
{ bounded: untested.bounded }
|
|
504
|
+
) + "\n" + untested.kept.join("\n")
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return assemble(sections, truncated || sectionTruncated);
|
|
508
|
+
}
|
|
509
|
+
__name(buildDigest, "buildDigest");
|
|
510
|
+
|
|
511
|
+
// src/suggest.ts
|
|
512
|
+
function unfence(raw) {
|
|
513
|
+
const open = /```[ \t]*[A-Za-z0-9_-]*[ \t]*\r?\n?/.exec(raw);
|
|
514
|
+
if (open === null) return raw;
|
|
515
|
+
const lead = raw.slice(0, open.index);
|
|
516
|
+
if (lead.includes("[") || lead.includes("{")) return raw;
|
|
517
|
+
const body = raw.slice(open.index + open[0].length);
|
|
518
|
+
const close = body.lastIndexOf("```");
|
|
519
|
+
return close === -1 ? raw : body.slice(0, close);
|
|
520
|
+
}
|
|
521
|
+
__name(unfence, "unfence");
|
|
522
|
+
function parseSuggestions(raw) {
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = JSON.parse(unfence(raw));
|
|
526
|
+
} catch (cause) {
|
|
527
|
+
return { ok: false, error: `the scan wrote invalid JSON: ${cause instanceof Error ? cause.message : String(cause)}` };
|
|
528
|
+
}
|
|
529
|
+
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.suggestions) ? parsed.suggestions : void 0;
|
|
530
|
+
if (list === void 0) {
|
|
531
|
+
return { ok: false, error: "the scan did not write a list of suggestions" };
|
|
532
|
+
}
|
|
533
|
+
const suggestions = [];
|
|
534
|
+
const seen = /* @__PURE__ */ new Set();
|
|
535
|
+
for (const entry of list) {
|
|
536
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
537
|
+
const row = entry;
|
|
538
|
+
const title = typeof row.title === "string" ? row.title.trim() : "";
|
|
539
|
+
if (title.length === 0) continue;
|
|
540
|
+
const stored = title.slice(0, MAX_TEXT);
|
|
541
|
+
const key = stored.toLowerCase();
|
|
542
|
+
if (seen.has(key)) continue;
|
|
543
|
+
seen.add(key);
|
|
544
|
+
const evidence = typeof row.evidence === "string" ? row.evidence.trim().slice(0, MAX_LABEL) : "";
|
|
545
|
+
suggestions.push({
|
|
546
|
+
title: stored,
|
|
547
|
+
rationale: typeof row.rationale === "string" ? row.rationale.trim().slice(0, MAX_DESC) : "",
|
|
548
|
+
priority: toPriority(row.priority),
|
|
549
|
+
// Absent optional fields are ABSENT KEYS, never '', matching TodoItem.
|
|
550
|
+
...evidence.length > 0 ? { evidence } : {}
|
|
551
|
+
});
|
|
552
|
+
if (suggestions.length >= MAX_SUGGESTIONS) break;
|
|
553
|
+
}
|
|
554
|
+
return { ok: true, suggestions };
|
|
555
|
+
}
|
|
556
|
+
__name(parseSuggestions, "parseSuggestions");
|
|
557
|
+
|
|
219
558
|
// src/index.ts
|
|
220
559
|
var todoItemSchema = z.object({
|
|
221
560
|
id: z.string().min(1),
|
|
@@ -240,7 +579,7 @@ var todoDomainSpec = {
|
|
|
240
579
|
name: "dsh_todo",
|
|
241
580
|
version: 2
|
|
242
581
|
};
|
|
243
|
-
var _replace_dec, _list_dec, _a, _init, _b;
|
|
582
|
+
var _readSuggestions_dec, _scanDigest_dec, _replace_dec, _list_dec, _a, _init, _b;
|
|
244
583
|
var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
245
584
|
/**
|
|
246
585
|
* @param ctx - host context carrying the workspace registry.
|
|
@@ -267,19 +606,19 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
|
267
606
|
* Runs once per harness home; a workspace whose db already has content is
|
|
268
607
|
* left untouched, so a half-finished migration never clobbers newer data.
|
|
269
608
|
*/
|
|
270
|
-
async [(_a = Service.init, _list_dec = [Remote], _replace_dec = [Remote], _a)]() {
|
|
609
|
+
async [(_a = Service.init, _list_dec = [Remote], _replace_dec = [Remote], _scanDigest_dec = [Remote], _readSuggestions_dec = [Remote], _a)]() {
|
|
271
610
|
this.ctx.effect(() => () => this.close(), "dsh-todo: close workspace databases");
|
|
272
611
|
const home = process.env.DSH_HOME;
|
|
273
612
|
if (!home) return;
|
|
274
|
-
const legacyPath =
|
|
275
|
-
const registryPath =
|
|
613
|
+
const legacyPath = join3(home, "storages", "dsh_todo.json");
|
|
614
|
+
const registryPath = join3(home, "storages", "workspace.json");
|
|
276
615
|
try {
|
|
277
616
|
if (!existsSync(legacyPath)) return;
|
|
278
|
-
const legacy = JSON.parse(
|
|
617
|
+
const legacy = JSON.parse(readFileSync2(legacyPath, "utf8"));
|
|
279
618
|
const records = legacy?.tables?.workspaces ?? {};
|
|
280
619
|
let mapping = {};
|
|
281
620
|
if (existsSync(registryPath)) {
|
|
282
|
-
mapping = JSON.parse(
|
|
621
|
+
mapping = JSON.parse(readFileSync2(registryPath, "utf8"))?.tables?.workspaces ?? {};
|
|
283
622
|
}
|
|
284
623
|
let importedItems = 0;
|
|
285
624
|
let importedWorkspaces = 0;
|
|
@@ -367,6 +706,69 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
|
367
706
|
return { ok: true, list: this.readList(db) };
|
|
368
707
|
});
|
|
369
708
|
}
|
|
709
|
+
async scanDigest(request) {
|
|
710
|
+
return buildDigest(this.workspaceDir(request?.workspaceId));
|
|
711
|
+
}
|
|
712
|
+
async readSuggestions(request) {
|
|
713
|
+
const dir = this.workspaceDir(request?.workspaceId);
|
|
714
|
+
const runId = request?.runId;
|
|
715
|
+
if (typeof runId !== "string" || !/^[a-z0-9]+$/.test(runId)) {
|
|
716
|
+
throw new Error("dsh-todo: runId must be a non-empty lowercase alphanumeric string");
|
|
717
|
+
}
|
|
718
|
+
const path = join3(dir, ...suggestionsFileFor(runId).split("/"));
|
|
719
|
+
this.sweepOrphanResults(dir, runId);
|
|
720
|
+
let raw;
|
|
721
|
+
try {
|
|
722
|
+
raw = readFileSync2(path, "utf8");
|
|
723
|
+
} catch (err) {
|
|
724
|
+
const code = err.code;
|
|
725
|
+
if (code === "ENOENT") return { status: "pending" };
|
|
726
|
+
return { status: "error", error: `dsh-todo: the scan result could not be read: ${code ?? String(err)}` };
|
|
727
|
+
}
|
|
728
|
+
const parsed = parseSuggestions(raw);
|
|
729
|
+
try {
|
|
730
|
+
unlinkSync(path);
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
if (!parsed.ok) return { status: "error", error: parsed.error };
|
|
734
|
+
return { status: "ready", suggestions: parsed.suggestions };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Delete every result file that is not the run currently reading.
|
|
738
|
+
*
|
|
739
|
+
* Per-run paths fix the cross-run bleed but introduce their own litter: a
|
|
740
|
+
* scan whose modal was closed still finishes and writes, and nobody ever
|
|
741
|
+
* reads that file. One orphan per abandoned scan accumulates in `.dsh`
|
|
742
|
+
* indefinitely, and abandoning a scan is the ordinary case, not the rare one.
|
|
743
|
+
*
|
|
744
|
+
* Sweeping on every poll — rather than at scan start — is deliberate and
|
|
745
|
+
* cheaper than it looks: a scan already polls this endpoint every 1.5s, one
|
|
746
|
+
* `readdir` of `.dsh` is trivially small beside the digest walk that preceded
|
|
747
|
+
* it, and it means a run started from a build with no sweep at all is still
|
|
748
|
+
* cleaned up by the next one. It also collects the legacy fixed-path file, so
|
|
749
|
+
* an upgrade needs no migration step.
|
|
750
|
+
*
|
|
751
|
+
* The regex is anchored on both ends: `.dsh` holds `todo.db` and whatever
|
|
752
|
+
* else the harness keeps there, and a sweep that guessed wider would delete a
|
|
753
|
+
* neighbour's data. Failure is swallowed throughout — this is housekeeping,
|
|
754
|
+
* and a scan that produced an answer must not fail over tidying.
|
|
755
|
+
*
|
|
756
|
+
* @param dir - the resolved workspace directory.
|
|
757
|
+
* @param runId - the run whose file must SURVIVE.
|
|
758
|
+
*/
|
|
759
|
+
sweepOrphanResults(dir, runId) {
|
|
760
|
+
const keep = suggestionsFileFor(runId).split("/").pop();
|
|
761
|
+
try {
|
|
762
|
+
for (const name of readdirSync2(join3(dir, SUGGESTIONS_DIR))) {
|
|
763
|
+
if (name === keep || !SUGGESTIONS_FILE_RE.test(name)) continue;
|
|
764
|
+
try {
|
|
765
|
+
unlinkSync(join3(dir, SUGGESTIONS_DIR, name));
|
|
766
|
+
} catch {
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
}
|
|
370
772
|
/** Queue one whole read/compare/write behind this workspace's prior write. */
|
|
371
773
|
async enqueue(workspaceId, run) {
|
|
372
774
|
const prior = this.tails.get(workspaceId) ?? Promise.resolve();
|
|
@@ -418,6 +820,8 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
|
418
820
|
_init = __decoratorStart(_b);
|
|
419
821
|
__decorateElement(_init, 1, "list", _list_dec, _TodoService);
|
|
420
822
|
__decorateElement(_init, 1, "replace", _replace_dec, _TodoService);
|
|
823
|
+
__decorateElement(_init, 1, "scanDigest", _scanDigest_dec, _TodoService);
|
|
824
|
+
__decorateElement(_init, 1, "readSuggestions", _readSuggestions_dec, _TodoService);
|
|
421
825
|
__decoratorMetadata(_init, _TodoService);
|
|
422
826
|
__name(_TodoService, "TodoService");
|
|
423
827
|
// Per-fiber service grants: the workspace registry property is only readable
|