@panaversity/ksor 0.0.21 → 0.0.22

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.22
4
+
5
+ ### Patch Changes
6
+
7
+ - ef538fa: Stage the record under a lock, so a build that evaluates its config more than
8
+ once cannot publish a short site.
9
+
10
+ A site build evaluates `source.config.ts` in more than one process — seven of
11
+ them staged the record in one measured build — and staging was destructive on
12
+ every evaluation: delete the whole per-audience stage, refill it. Two of those
13
+ overlapping deleted a tree the other was copying into. Six
14
+ concurrent evaluations of a 150-document record failed 42 of 48 runs — `ENOENT`
15
+ and `EINVAL` out of `copyFileSync`, `ENOTEMPTY` out of `rmSync` despite its
16
+ retries, and, in 27 of the 48, no error at all: staging returned success and
17
+ handed the build a stage a third of the record short. That last shape is the one
18
+ that matters — a crash fails a build, a short stage publishes one, with
19
+ documents missing from `/docs`, `llms.txt` and the search index and nothing
20
+ saying so.
21
+
22
+ Staging now takes a lock file (`system/site/.staged-knowledge.lock`, gitignored,
23
+ stamped with the holder's pid so a killed build's lock is broken rather than
24
+ waited on), and an evaluation that finds the stage already holding exactly its
25
+ plan — byte for byte — leaves it alone instead of rebuilding it. Together those
26
+ mean the destructive path runs once per build, alone. No behaviour changes for a
27
+ build that was already succeeding.
28
+
3
29
  ## 0.0.21
4
30
 
5
31
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
5
5
  "keywords": [
6
6
  "abstention",
@@ -8,6 +8,9 @@ system/site/out/
8
8
  # the per-audience copy of the record a build stages — a filtered derivative,
9
9
  # never a second record; committing it would publish what a build excluded
10
10
  system/site/.staged-knowledge/
11
+ # and the lock that keeps one evaluation of a build staging at a time; it only
12
+ # outlives a build that was killed mid-stage, and the next build clears it
13
+ system/site/.staged-knowledge.lock
11
14
  *.tsbuildinfo
12
15
 
13
16
  # secrets never enter the record — system/ is their future home (serve)
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  copyFileSync,
3
+ existsSync,
3
4
  mkdirSync,
4
5
  readFileSync,
5
6
  readdirSync,
6
7
  rmSync,
7
8
  statSync,
8
9
  watch,
10
+ writeFileSync,
9
11
  } from "node:fs";
10
12
  import path from "node:path";
11
13
 
@@ -343,47 +345,191 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
343
345
  return { files: [...documents, ...assets], documents: documents.length, total };
344
346
  }
345
347
 
348
+ /** How often a waiter looks again. */
349
+ const LOCK_POLL_MS = 25;
350
+ /** How long a wait goes unexplained. A build that looks hung must say why. */
351
+ const LOCK_ANNOUNCE_MS = 10_000;
352
+
353
+ /** Synchronous, because everything on this path is: a bundler cannot await. */
354
+ function sleepSync(ms: number): void {
355
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
356
+ }
357
+
358
+ function isAlive(pid: number): boolean {
359
+ try {
360
+ process.kill(pid, 0);
361
+ return true;
362
+ } catch (error) {
363
+ // EPERM is a process that exists and is not ours to signal.
364
+ return (error as NodeJS.ErrnoException).code === "EPERM";
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Is this lock abandoned — stamped with a process that no longer exists?
370
+ *
371
+ * Blank is the one ambiguous read: the holder writes its pid in the same call
372
+ * that creates the file, so a blank lock is either a holder caught between the
373
+ * two (microseconds) or one that died there (forever). Looking twice tells
374
+ * them apart, and only the second look may break a lock.
375
+ */
376
+ function lockIsAbandoned(lockFile: string): boolean {
377
+ for (const look of [0, 1]) {
378
+ let stamp: string;
379
+ try {
380
+ stamp = readFileSync(lockFile, "utf8").trim();
381
+ } catch {
382
+ // Released while we read it; the next acquire attempt takes it.
383
+ return false;
384
+ }
385
+ const pid = Number(stamp);
386
+ if (Number.isInteger(pid) && pid > 0) return !isAlive(pid);
387
+ if (look === 0) sleepSync(LOCK_POLL_MS * 2);
388
+ }
389
+ return true;
390
+ }
391
+
392
+ /**
393
+ * Hold the stage lock for the duration of `work`: ONE evaluation writes the
394
+ * stage at a time, and this file says which.
395
+ *
396
+ * A build evaluates `source.config.ts` in more than one process — SEVEN of
397
+ * them staged the record in one measured `next build` of a scaffolded site
398
+ * (2026-08-23) — and staging was destructive on every evaluation: delete the
399
+ * whole stage, refill it. Two of those overlapping is not a rare interleaving,
400
+ * it is what seven of them do — six concurrent evaluations of a 150-document
401
+ * record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of `copyFileSync` (the reported one,
402
+ * issue #100), `ENOTEMPTY` out of `rmSync` *with* its retries already in
403
+ * place, and — 27 of the 48, the majority — no error at all: staging returned
404
+ * success and handed the build a stage a third of the record short.
405
+ *
406
+ * The silent shape is why this is a lock and not another retry. A crash fails
407
+ * a build; a short stage PUBLISHES one, with documents missing from /docs,
408
+ * llms.txt and the search index, and nothing anywhere saying so.
409
+ *
410
+ * `wx` is the whole primitive: create-if-absent, atomically, on every
411
+ * filesystem Node supports — and it stamps the holder's pid in the same call,
412
+ * so a waiter can tell a live holder from a killed one.
413
+ *
414
+ * Waiting on a LIVE holder is unbounded on purpose: it is another evaluation
415
+ * of the same build, staging the same bytes from the same record, and this
416
+ * build is not finished until it has. Unbounded is not silent, though — a wait
417
+ * long enough to look like a hang names what it is waiting for.
418
+ */
419
+ function withStageLock<T>(stageDir: string, work: () => T): T {
420
+ const lockFile = `${stageDir}.lock`;
421
+ let waited = 0;
422
+ let announced = false;
423
+ for (;;) {
424
+ try {
425
+ writeFileSync(lockFile, String(process.pid), { flag: "wx" });
426
+ break;
427
+ } catch (error) {
428
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
429
+ if (lockIsAbandoned(lockFile)) {
430
+ rmSync(lockFile, { force: true });
431
+ continue;
432
+ }
433
+ sleepSync(LOCK_POLL_MS);
434
+ waited += LOCK_POLL_MS;
435
+ if (waited >= LOCK_ANNOUNCE_MS && !announced) {
436
+ announced = true;
437
+ console.warn(
438
+ `[ksor] waiting on ${path.basename(lockFile)} — another evaluation of this build is ` +
439
+ "staging the record. Delete that file if no build is running.",
440
+ );
441
+ }
442
+ }
443
+ }
444
+ try {
445
+ return work();
446
+ } finally {
447
+ rmSync(lockFile, { force: true });
448
+ }
449
+ }
450
+
346
451
  /**
347
452
  * Remove the stage, asking for the retries this exact failure needs.
348
453
  *
349
- * `force: true` suppresses ENOENT; it does NOT retry anything. Node retries
350
- * EBUSY / EMFILE / ENFILE / ENOTEMPTY / EPERM only when `maxRetries` is set,
351
- * and it defaults to zero. The build evaluates `source.config.ts` more than
352
- * once when the bundler wants it in more than one place, so two runs can
353
- * overlap: one removing the stage while the other is still copying into it.
354
- * That surfaced as `ENOTEMPTY` out of `rmSync` and failed the whole site build
355
- * (CI, 2026-08-21) a race that is safe to lose, because the stage is a
356
- * deterministic function of the record and the denylist, so redoing it produces
357
- * the same bytes.
454
+ * Callers hold the stage lock, so no OTHER evaluation is writing here — but
455
+ * `force: true` suppresses ENOENT and does NOT retry anything, and Node
456
+ * retries EBUSY / EMFILE / ENFILE / ENOTEMPTY / EPERM only when `maxRetries`
457
+ * is set (it defaults to zero). Those are what a Windows indexer or an
458
+ * antivirus scanner holding a handle looks like not ksor, and not something
459
+ * the lock can serialise. Losing that race is safe: the stage is a
460
+ * deterministic function of the record and the denylist, so redoing it
461
+ * produces the same bytes.
358
462
  */
359
463
  function removeStage(stageDir: string): void {
360
464
  rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
361
465
  }
362
466
 
363
- /** Fill a clean stage with exactly the set this build may publish. */
364
- function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
365
- // The old stage goes first, before any refusal can throw: a refused build
366
- // that leaves the previous, more permissive stage on disk hands the next
367
- // careless build a filtered copy nothing governs (review finding,
368
- // 2026-08-19).
369
- removeStage(stageDir);
370
- const plan = planStage(recordDir, denied);
371
- // An empty record is its own problem, reported by the page that renders it;
372
- // an empty AUDIENCE is a misconfiguration that would otherwise surface as
373
- // "the record has no documents" against a record full of them.
374
- if (plan.documents === 0 && plan.total > 0) {
375
- refuse(
376
- "ksor-audience-empty",
377
- `no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
378
- "a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this audience's slice of it is",
379
- "build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
380
- );
467
+ /**
468
+ * Does the stage already hold EXACTLY this plan, byte for byte?
469
+ *
470
+ * The wipe-and-refill is the destructive half of staging, and it is pure waste
471
+ * whenever the answer is yes which is every evaluation after the first in
472
+ * one build, since the plan is a deterministic function of the record and the
473
+ * denylist. Skipping it is not an optimisation: while a wipe is running there
474
+ * is a window in which the stage is not the record, and an evaluation that has
475
+ * already returned is reading it. The lock stops two writers colliding; this
476
+ * stops the second writer existing at all.
477
+ *
478
+ * Bytes, not names and not timestamps: the alternative is serving a previous
479
+ * build's copy of a document that has since been edited.
480
+ */
481
+ function stageHolds(recordDir: string, stageDir: string, plan: StagePlan): boolean {
482
+ let staged: string[];
483
+ try {
484
+ staged = walkFiles(stageDir);
485
+ } catch {
486
+ return false;
381
487
  }
382
- for (const from of plan.files) {
383
- const to = path.join(stageDir, path.relative(recordDir, from));
384
- mkdirSync(path.dirname(to), { recursive: true });
385
- copyFileSync(from, to);
488
+ if (staged.length !== plan.files.length) return false;
489
+ const expected = new Map(
490
+ plan.files.map((from) => [path.join(stageDir, path.relative(recordDir, from)), from]),
491
+ );
492
+ for (const file of staged) {
493
+ const from = expected.get(file);
494
+ if (from === undefined) return false;
495
+ if (!readFileSync(from).equals(readFileSync(file))) return false;
386
496
  }
497
+ return true;
498
+ }
499
+
500
+ /** Fill a clean stage with exactly the set this build may publish. */
501
+ function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
502
+ withStageLock(stageDir, () => {
503
+ let plan: StagePlan;
504
+ try {
505
+ plan = planStage(recordDir, denied);
506
+ // An empty record is its own problem, reported by the page that renders
507
+ // it; an empty AUDIENCE is a misconfiguration that would otherwise
508
+ // surface as "the record has no documents" against a record full of them.
509
+ if (plan.documents === 0 && plan.total > 0) {
510
+ refuse(
511
+ "ksor-audience-empty",
512
+ `no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
513
+ "a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this audience's slice of it is",
514
+ "build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
515
+ );
516
+ }
517
+ } catch (error) {
518
+ // No refusal may leave the previous, more permissive stage on disk: it
519
+ // hands the next careless build a filtered copy nothing governs (review
520
+ // finding, 2026-08-19). The removal used to lead this function, which is
521
+ // why nothing could ask whether the stage was already correct.
522
+ removeStage(stageDir);
523
+ throw error;
524
+ }
525
+ if (stageHolds(recordDir, stageDir, plan)) return;
526
+ removeStage(stageDir);
527
+ for (const from of plan.files) {
528
+ const to = path.join(stageDir, path.relative(recordDir, from));
529
+ mkdirSync(path.dirname(to), { recursive: true });
530
+ copyFileSync(from, to);
531
+ }
532
+ });
387
533
  }
388
534
 
389
535
  /**
@@ -421,13 +567,17 @@ function refuseVisibilityWithoutAudiences(recordDir: string): void {
421
567
  * published build is always staged from scratch.
422
568
  */
423
569
  function refreshStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
424
- const permitted = new Set(planStage(recordDir, denied).files);
425
- for (const staged of walkFiles(stageDir)) {
426
- const from = path.join(recordDir, path.relative(stageDir, staged));
427
- if (!permitted.has(from)) continue;
428
- if (readFileSync(from).equals(readFileSync(staged))) continue;
429
- copyFileSync(from, staged);
430
- }
570
+ // Under the lock like every other write here: a save landing while another
571
+ // evaluation is refilling the stage is the same race from the other side.
572
+ withStageLock(stageDir, () => {
573
+ const permitted = new Set(planStage(recordDir, denied).files);
574
+ for (const staged of walkFiles(stageDir)) {
575
+ const from = path.join(recordDir, path.relative(stageDir, staged));
576
+ if (!permitted.has(from)) continue;
577
+ if (readFileSync(from).equals(readFileSync(staged))) continue;
578
+ copyFileSync(from, staged);
579
+ }
580
+ });
431
581
  }
432
582
 
433
583
  let watching = false;
@@ -484,8 +634,11 @@ export function knowledgeSourceDir(): string {
484
634
  // Nothing to filter — serve the record itself, the level-0 fast path.
485
635
  // A stage left behind by an earlier model would be a filtered copy of the
486
636
  // record nothing governs any more — removed before the refusal below can
487
- // throw, so a refused build never leaves one behind either.
488
- removeStage(stageDir);
637
+ // throw, so a refused build never leaves one behind either. Under the lock,
638
+ // because two evaluations removing one tree is the `ENOTEMPTY` shape of the
639
+ // same race; the existence check keeps a record that never stages from
640
+ // taking a lock on every build.
641
+ if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
489
642
  refuseVisibilityWithoutAudiences(recordDir);
490
643
  return RECORD_DIR;
491
644
  }