@plinth-music/cli 0.22.0 → 0.23.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/dist/cli.js CHANGED
@@ -7584,7 +7584,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
7584
7584
  // package.json
7585
7585
  var package_default = {
7586
7586
  name: "@plinth-music/cli",
7587
- version: "0.22.0",
7587
+ version: "0.23.1",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -22148,12 +22148,28 @@ var BASE_ROW = {
22148
22148
  };
22149
22149
  var TombstoneSchema = exports_external.object({
22150
22150
  ...BASE_ROW,
22151
- deleted_at: exports_external.string().datetime({ offset: true })
22151
+ deleted_at: exports_external.string().datetime({ offset: true }),
22152
+ permanent_delete: exports_external.literal(true).optional(),
22153
+ purge_generation: exports_external.string().uuid().optional(),
22154
+ retirement_sequence: exports_external.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional()
22155
+ }).superRefine((row, ctx) => {
22156
+ const present = [
22157
+ row.permanent_delete !== undefined,
22158
+ row.purge_generation !== undefined,
22159
+ row.retirement_sequence !== undefined
22160
+ ];
22161
+ if (present.some(Boolean) && !present.every(Boolean)) {
22162
+ ctx.addIssue({
22163
+ code: "custom",
22164
+ message: "permanent tombstone fields must be present together"
22165
+ });
22166
+ }
22152
22167
  });
22153
22168
  function makePageResponseSchema(responseKey, liveSchema) {
22154
22169
  return exports_external.object({
22155
22170
  [responseKey]: exports_external.array(exports_external.union([liveSchema, TombstoneSchema])),
22156
- next_cursor: exports_external.string().nullable()
22171
+ next_cursor: exports_external.string().nullable(),
22172
+ high_water: exports_external.string().nullable().optional()
22157
22173
  });
22158
22174
  }
22159
22175
  function makeParsePageResponse(schema, responseKey) {
@@ -22161,7 +22177,8 @@ function makeParsePageResponse(schema, responseKey) {
22161
22177
  const parsed = schema.parse(json2);
22162
22178
  return {
22163
22179
  rows: parsed[responseKey],
22164
- next_cursor: parsed.next_cursor
22180
+ next_cursor: parsed.next_cursor,
22181
+ high_water: parsed.high_water ?? null
22165
22182
  };
22166
22183
  };
22167
22184
  }
@@ -23893,6 +23910,17 @@ function moveEntityFile(workspaceSlug, entityType, fromSlug, toSlug, beforeMove)
23893
23910
  throw err;
23894
23911
  }
23895
23912
  }
23913
+ function deleteMirrorFile(workspaceSlug, entityType, entitySlug) {
23914
+ const target = entityFilePath(workspaceSlug, entityType, entitySlug);
23915
+ try {
23916
+ unlinkSync(target);
23917
+ return true;
23918
+ } catch (err) {
23919
+ if (err?.code === "ENOENT")
23920
+ return false;
23921
+ throw err;
23922
+ }
23923
+ }
23896
23924
  var ARTIST_MEMORY_ANCHOR = "memory.md";
23897
23925
  function artistMemoryFilePath(workspaceSlug, entitySlug) {
23898
23926
  assertSlug("entity slug", entitySlug);
@@ -25319,7 +25347,8 @@ var WorkspaceEntrySchema = exports_external.object({
25319
25347
  last_synced_at: exports_external.string().datetime({ offset: true }).nullable(),
25320
25348
  workspace_name: exports_external.string().min(1).optional(),
25321
25349
  member: MemberPrincipalSchema.optional(),
25322
- sync_cursors: SyncCursorsSchema.optional()
25350
+ sync_cursors: SyncCursorsSchema.optional(),
25351
+ sync_high_water: SyncCursorsSchema.optional()
25323
25352
  });
25324
25353
  var ConfigFileSchema = exports_external.object({
25325
25354
  version: exports_external.literal(1),
@@ -25381,7 +25410,8 @@ function carryForwardSyncState(prior, entry) {
25381
25410
  return {
25382
25411
  ...entry,
25383
25412
  last_synced_at: prior.last_synced_at,
25384
- ...prior.sync_cursors !== undefined ? { sync_cursors: prior.sync_cursors } : {}
25413
+ ...prior.sync_cursors !== undefined ? { sync_cursors: prior.sync_cursors } : {},
25414
+ ...prior.sync_high_water !== undefined ? { sync_high_water: prior.sync_high_water } : {}
25385
25415
  };
25386
25416
  }
25387
25417
  function upsertWorkspace(slug, entry, options2 = {}) {
@@ -25422,6 +25452,23 @@ function updateSyncCursor(slug, entityType, cursor) {
25422
25452
  writeAtomic(next);
25423
25453
  return next;
25424
25454
  }
25455
+ function updateSyncHighWater(slug, entityType, mark) {
25456
+ const existing = loadConfig();
25457
+ if (!existing) {
25458
+ throw new Error(`No config. Run \`plinth login --workspace ${slug}\` first.`);
25459
+ }
25460
+ const entry = requireWorkspace(existing, slug);
25461
+ if ((entry.sync_high_water?.[entityType] ?? null) === mark)
25462
+ return;
25463
+ const nextEntry = {
25464
+ ...entry,
25465
+ sync_high_water: { ...entry.sync_high_water, [entityType]: mark }
25466
+ };
25467
+ writeAtomic({
25468
+ ...existing,
25469
+ workspaces: { ...existing.workspaces, [slug]: nextEntry }
25470
+ });
25471
+ }
25425
25472
  function setMemberPrincipal(slug, member) {
25426
25473
  const existing = loadConfig();
25427
25474
  if (!existing)
@@ -26709,7 +26756,7 @@ function allFileEntries(state) {
26709
26756
  return Object.values(state.files ?? {});
26710
26757
  }
26711
26758
  function fileEntriesEqual(a, b) {
26712
- return a.id === b.id && a.path === b.path && a.content_hash === b.content_hash && a.size_bytes === b.size_bytes && a.mime_type === b.mime_type && a.updated_at === b.updated_at && a.inode === b.inode;
26759
+ return a.id === b.id && a.path === b.path && a.content_hash === b.content_hash && a.size_bytes === b.size_bytes && a.mime_type === b.mime_type && a.updated_at === b.updated_at && a.inode === b.inode && a.text_hash === b.text_hash;
26713
26760
  }
26714
26761
  function clearFrontmatterIdBackfillMarker(state) {
26715
26762
  if (state.frontmatter_id_backfill_version === undefined) {
@@ -29609,6 +29656,13 @@ class ApiError extends Error {
29609
29656
  this.name = "ApiError";
29610
29657
  }
29611
29658
  }
29659
+ var PermanentNameRetiredSchema = exports_external.object({
29660
+ code: exports_external.literal("permanent_name_retired"),
29661
+ entity_kind: exports_external.enum(["document", "workspace_file"]),
29662
+ logical_key: exports_external.string().min(1),
29663
+ action: exports_external.literal("refresh_required"),
29664
+ error: exports_external.string().optional()
29665
+ });
29612
29666
  function parseRetryAfterMs(headerValue, now = () => Date.now()) {
29613
29667
  if (!headerValue)
29614
29668
  return;
@@ -29622,6 +29676,92 @@ function parseRetryAfterMs(headerValue, now = () => Date.now()) {
29622
29676
  const delta = dateMs - now();
29623
29677
  return delta > 0 ? delta : 0;
29624
29678
  }
29679
+ var OperationEnvelopeBase = exports_external.object({
29680
+ entity_kind: exports_external.enum(["document", "workspace_file"]),
29681
+ operation_id: exports_external.string().uuid()
29682
+ });
29683
+ var OperationBoundBase = OperationEnvelopeBase.extend({
29684
+ logical_key: exports_external.string().min(1),
29685
+ payload_digest: exports_external.string().regex(/^[0-9a-f]{64}$/)
29686
+ });
29687
+ var OperationResolutionSchema = exports_external.discriminatedUnion("status", [
29688
+ OperationBoundBase.extend({ status: exports_external.literal("pending") }),
29689
+ OperationBoundBase.extend({ status: exports_external.literal("finalized"), live: exports_external.unknown() }),
29690
+ OperationBoundBase.extend({ status: exports_external.literal("terminal_cleaned") }),
29691
+ OperationEnvelopeBase.extend({ status: exports_external.literal("retired_not_found") })
29692
+ ]);
29693
+ var CurrentNamespaceSchema = exports_external.object({
29694
+ entity_kind: exports_external.enum(["document", "workspace_file"]),
29695
+ logical_key: exports_external.string().min(1),
29696
+ latest_retirement: exports_external.object({
29697
+ purge_generation: exports_external.string().uuid(),
29698
+ retirement_sequence: exports_external.number().int().positive().max(Number.MAX_SAFE_INTEGER)
29699
+ }).nullable(),
29700
+ live_owner: exports_external.object({ id: exports_external.string().uuid() }).nullable()
29701
+ });
29702
+ async function resolvePermanentDeletionOperation(args) {
29703
+ const { baseUrl, pat, entityKind, operationId, fetchImpl = fetch } = args;
29704
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/permanent-deletions/operations/resolve`;
29705
+ let res;
29706
+ try {
29707
+ res = await fetchImpl(url2, {
29708
+ method: "POST",
29709
+ headers: {
29710
+ Authorization: `Bearer ${pat}`,
29711
+ Accept: "application/json",
29712
+ "Content-Type": "application/json"
29713
+ },
29714
+ body: JSON.stringify({ entity_kind: entityKind, operation_id: operationId })
29715
+ });
29716
+ } catch (error52) {
29717
+ const detail = error52 instanceof Error ? error52.message : "network error";
29718
+ throw new ApiError(0, `Could not reach ${url2}: ${detail}`);
29719
+ }
29720
+ if (res.status === 401)
29721
+ throw new ApiError(401, "Token was rejected by the server.");
29722
+ if (res.status === 429) {
29723
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
29724
+ }
29725
+ if (!res.ok) {
29726
+ throw new ApiError(res.status, `resolve permanent deletion operation failed with HTTP ${res.status}`);
29727
+ }
29728
+ const parsed = OperationResolutionSchema.safeParse(await res.json());
29729
+ if (!parsed.success) {
29730
+ throw new ApiError(res.status, `operation resolution response did not match the expected shape: ${parsed.error.message}`);
29731
+ }
29732
+ return parsed.data;
29733
+ }
29734
+ async function getCurrentPermanentNamespace(args) {
29735
+ const { baseUrl, pat, entityKind, logicalKey, fetchImpl = fetch } = args;
29736
+ const params = new URLSearchParams({ entity_kind: entityKind, logical_key: logicalKey });
29737
+ const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/permanent-deletions/namespaces/current?${params}`;
29738
+ let res;
29739
+ try {
29740
+ res = await fetchImpl(url2, {
29741
+ method: "GET",
29742
+ headers: { Authorization: `Bearer ${pat}`, Accept: "application/json" }
29743
+ });
29744
+ } catch (error52) {
29745
+ const detail = error52 instanceof Error ? error52.message : "network error";
29746
+ throw new ApiError(0, `Could not reach ${url2}: ${detail}`);
29747
+ }
29748
+ if (res.status === 401)
29749
+ throw new ApiError(401, "Token was rejected by the server.");
29750
+ if (res.status === 429) {
29751
+ throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
29752
+ }
29753
+ if (!res.ok) {
29754
+ throw new ApiError(res.status, `get current permanent namespace failed with HTTP ${res.status}`);
29755
+ }
29756
+ const parsed = CurrentNamespaceSchema.safeParse(await res.json());
29757
+ if (!parsed.success) {
29758
+ throw new ApiError(res.status, `current permanent namespace response did not match the expected shape: ${parsed.error.message}`);
29759
+ }
29760
+ if (parsed.data.entity_kind !== entityKind || parsed.data.logical_key !== logicalKey) {
29761
+ throw new ApiError(res.status, "current permanent namespace response identity mismatch");
29762
+ }
29763
+ return parsed.data;
29764
+ }
29625
29765
  async function whoami(baseUrl, pat, fetchImpl = fetch) {
29626
29766
  const url2 = `${baseUrl.replace(/\/$/, "")}/api/auth/cli/whoami`;
29627
29767
  let res;
@@ -29797,6 +29937,16 @@ async function patchEntity(args) {
29797
29937
  }
29798
29938
  if (res.status === 409) {
29799
29939
  const json2 = await res.json();
29940
+ const retiredParse = PermanentNameRetiredSchema.safeParse(json2);
29941
+ if (retiredParse.success) {
29942
+ return {
29943
+ ok: false,
29944
+ code: 409,
29945
+ reason: "permanent_name_retired",
29946
+ entityKind: retiredParse.data.entity_kind,
29947
+ logicalKey: retiredParse.data.logical_key
29948
+ };
29949
+ }
29800
29950
  const contextParse = ContextDocConflictResponseSchema.safeParse(json2);
29801
29951
  if (contextParse.success) {
29802
29952
  const existing = strategy.singleResponseSchema.safeParse(contextParse.data.existing);
@@ -29853,17 +30003,33 @@ async function patchEntity(args) {
29853
30003
  throw new ApiError(res.status, `patch ${strategy.type} failed with HTTP ${res.status}`);
29854
30004
  }
29855
30005
  async function createEntity(args) {
29856
- const { strategy, baseUrl, pat, payload, fetchImpl = fetch } = args;
30006
+ const {
30007
+ strategy,
30008
+ baseUrl,
30009
+ pat,
30010
+ payload,
30011
+ retiredGeneration,
30012
+ operationId,
30013
+ fetchImpl = fetch
30014
+ } = args;
29857
30015
  const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/${strategy.apiSegment}`;
30016
+ if (retiredGeneration === undefined !== (operationId === undefined)) {
30017
+ throw new Error("retired generation and create operation id must be supplied together");
30018
+ }
30019
+ const headers = {
30020
+ Authorization: `Bearer ${pat}`,
30021
+ Accept: "application/json",
30022
+ "Content-Type": "application/json"
30023
+ };
30024
+ if (retiredGeneration) {
30025
+ headers["X-Plinth-Retired-Generation"] = retiredGeneration;
30026
+ headers["X-Plinth-Create-Operation-Id"] = operationId;
30027
+ }
29858
30028
  let res;
29859
30029
  try {
29860
30030
  res = await fetchImpl(url2, {
29861
30031
  method: "POST",
29862
- headers: {
29863
- Authorization: `Bearer ${pat}`,
29864
- Accept: "application/json",
29865
- "Content-Type": "application/json"
29866
- },
30032
+ headers,
29867
30033
  body: JSON.stringify(payload)
29868
30034
  });
29869
30035
  } catch (err) {
@@ -29880,6 +30046,16 @@ async function createEntity(args) {
29880
30046
  }
29881
30047
  if (res.status === 409) {
29882
30048
  const json2 = await res.json();
30049
+ const retiredParse = PermanentNameRetiredSchema.safeParse(json2);
30050
+ if (retiredParse.success) {
30051
+ return {
30052
+ ok: false,
30053
+ code: 409,
30054
+ reason: "permanent_name_retired",
30055
+ entityKind: retiredParse.data.entity_kind,
30056
+ logicalKey: retiredParse.data.logical_key
30057
+ };
30058
+ }
29883
30059
  const contextParse = ContextDocConflictResponseSchema.safeParse(json2);
29884
30060
  if (contextParse.success) {
29885
30061
  const existing2 = strategy.singleResponseSchema.safeParse(contextParse.data.existing);
@@ -30023,12 +30199,25 @@ var FileTombstoneSchema = exports_external.object({
30023
30199
  id: exports_external.string().uuid(),
30024
30200
  path: exports_external.string().min(1),
30025
30201
  updated_at: exports_external.string().datetime({ offset: true }),
30026
- deleted_at: exports_external.string().datetime({ offset: true })
30202
+ deleted_at: exports_external.string().datetime({ offset: true }),
30203
+ permanent_delete: exports_external.literal(true).optional(),
30204
+ purge_generation: exports_external.string().uuid().optional(),
30205
+ retirement_sequence: exports_external.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional()
30206
+ }).superRefine((row, ctx) => {
30207
+ const present2 = [
30208
+ row.permanent_delete !== undefined,
30209
+ row.purge_generation !== undefined,
30210
+ row.retirement_sequence !== undefined
30211
+ ];
30212
+ if (present2.some(Boolean) && !present2.every(Boolean)) {
30213
+ ctx.addIssue({ code: "custom", message: "permanent tombstone fields must be present together" });
30214
+ }
30027
30215
  });
30028
30216
  var FileRowSchema = exports_external.union([FileLiveSchema, FileTombstoneSchema]);
30029
30217
  var FilesPageSchema = exports_external.object({
30030
30218
  files: exports_external.array(FileRowSchema),
30031
- next_cursor: exports_external.string().nullable()
30219
+ next_cursor: exports_external.string().nullable(),
30220
+ high_water: exports_external.string().nullable().optional()
30032
30221
  });
30033
30222
  var FileWithDownloadSchema = FileLiveSchema.extend({
30034
30223
  download_url: exports_external.string().url().nullable()
@@ -30081,7 +30270,11 @@ async function syncFilesPage(args) {
30081
30270
  if (!parsed.success) {
30082
30271
  throw new ApiError(res.status, `sync files response did not match the expected shape: ${parsed.error.message}`);
30083
30272
  }
30084
- return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
30273
+ return {
30274
+ rows: parsed.data.files,
30275
+ next_cursor: parsed.data.next_cursor,
30276
+ high_water: parsed.data.high_water ?? null
30277
+ };
30085
30278
  }
30086
30279
  async function getFile(args) {
30087
30280
  const { baseUrl, pat, id, fetchImpl = fetch } = args;
@@ -30162,6 +30355,8 @@ async function postFile(args) {
30162
30355
  contentHash,
30163
30356
  mimeType,
30164
30357
  expectedUpdatedAt,
30358
+ retiredGeneration,
30359
+ operationId,
30165
30360
  fetchImpl = fetch
30166
30361
  } = args;
30167
30362
  const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files`;
@@ -30174,6 +30369,13 @@ async function postFile(args) {
30174
30369
  };
30175
30370
  if (expectedUpdatedAt)
30176
30371
  headers["X-Plinth-Expected-Updated-At"] = expectedUpdatedAt;
30372
+ if (retiredGeneration === undefined !== (operationId === undefined)) {
30373
+ throw new Error("retired generation and create operation id must be supplied together");
30374
+ }
30375
+ if (retiredGeneration) {
30376
+ headers["X-Plinth-Retired-Generation"] = retiredGeneration;
30377
+ headers["X-Plinth-Create-Operation-Id"] = operationId;
30378
+ }
30177
30379
  let res;
30178
30380
  try {
30179
30381
  res = await fetchImpl(url2, { method: "POST", headers, body: bytes });
@@ -30200,6 +30402,22 @@ async function postFile(args) {
30200
30402
  }
30201
30403
  if (res.status === 409) {
30202
30404
  const json2 = await res.json();
30405
+ const retired = exports_external.object({
30406
+ code: exports_external.literal("permanent_name_retired"),
30407
+ entity_kind: exports_external.literal("workspace_file"),
30408
+ logical_key: exports_external.string().min(1),
30409
+ action: exports_external.literal("refresh_required"),
30410
+ error: exports_external.string().optional()
30411
+ }).safeParse(json2);
30412
+ if (retired.success) {
30413
+ return {
30414
+ ok: false,
30415
+ code: 409,
30416
+ reason: "permanent_name_retired",
30417
+ entityKind: retired.data.entity_kind,
30418
+ logicalKey: retired.data.logical_key
30419
+ };
30420
+ }
30203
30421
  const parsed = PreconditionSchema.safeParse(json2);
30204
30422
  if (!parsed.success) {
30205
30423
  throw new ApiError(409, `post file 409 body did not match precondition_failed shape`);
@@ -30327,6 +30545,12 @@ function fileDeleteSuppressedLine(f) {
30327
30545
  function entityDeleteSuppressedLine(f) {
30328
30546
  return `[ENTITY_DELETE_SUPPRESSED entityType=${f.entityType} slug=${f.slug} reason=${f.reason}]`;
30329
30547
  }
30548
+ function entityRestoreDeferredLine(f) {
30549
+ return `[ENTITY_RESTORE_DEFERRED entityType=${f.entityType} slug=${f.slug} wait_s=${f.waitSeconds}]`;
30550
+ }
30551
+ function fileRestoreDeferredLine(f) {
30552
+ return `[FILE_RESTORE_DEFERRED path=${f.path} wait_s=${f.waitSeconds}]`;
30553
+ }
30330
30554
  function fileOverCapLine(f) {
30331
30555
  return `[FILE_OVER_CAP path=${f.path} size=${f.size} max=${f.max} max_from=${f.source}]`;
30332
30556
  }
@@ -34096,7 +34320,7 @@ var sessionCommand = defineCommand({
34096
34320
 
34097
34321
  // src/commands/start.ts
34098
34322
  var import_gray_matter14 = __toESM(require_gray_matter(), 1);
34099
- import { existsSync as existsSync9, readFileSync as readFileSync28, statSync as statSync18 } from "node:fs";
34323
+ import { existsSync as existsSync10, readFileSync as readFileSync30, statSync as statSync18 } from "node:fs";
34100
34324
 
34101
34325
  // src/lib/daemon-log.ts
34102
34326
  import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync9, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
@@ -34340,7 +34564,7 @@ class DestructiveGate {
34340
34564
  }
34341
34565
 
34342
34566
  // src/lib/resolve-pending.ts
34343
- import { readFileSync as readFileSync19 } from "node:fs";
34567
+ import { readFileSync as readFileSync20 } from "node:fs";
34344
34568
 
34345
34569
  // src/lib/conflicts.ts
34346
34570
  import { mkdirSync as mkdirSync10, readFileSync as readFileSync15, readdirSync as readdirSync6, statSync as statSync6, writeFileSync as writeFileSync7 } from "node:fs";
@@ -34359,6 +34583,26 @@ function stashConflict(args) {
34359
34583
  writeFileSync7(target, args.contents, { mode: 384 });
34360
34584
  return target;
34361
34585
  }
34586
+ function stashEntityConflictOnce(args) {
34587
+ const dir = conflictsRoot(args.workspaceSlug);
34588
+ const head = `${args.entityType}.${args.entitySlug}.`;
34589
+ const tail = new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${args.side}\\.md$`);
34590
+ let names = [];
34591
+ try {
34592
+ names = readdirSync6(dir);
34593
+ } catch {
34594
+ names = [];
34595
+ }
34596
+ const newest = names.filter((name) => name.startsWith(head) && tail.test(name.slice(head.length))).sort().pop();
34597
+ if (newest !== undefined) {
34598
+ const path5 = join12(dir, newest);
34599
+ try {
34600
+ if (readFileSync15(path5, "utf8") === args.contents)
34601
+ return { path: path5, reused: true };
34602
+ } catch {}
34603
+ }
34604
+ return { path: stashConflict(args), reused: false };
34605
+ }
34362
34606
  function fileConflictPath(args) {
34363
34607
  const { workspaceSlug, relPath, timestamp, side } = args;
34364
34608
  const iso = timestamp.toISOString().replace(/[:.]/g, "-");
@@ -34448,12 +34692,646 @@ function listConflictStashes(workspaceSlug) {
34448
34692
  }
34449
34693
 
34450
34694
  // src/lib/files/push.ts
34451
- import { readFileSync as readFileSync16, statSync as statSync7 } from "node:fs";
34695
+ import { readFileSync as readFileSync17, statSync as statSync7 } from "node:fs";
34696
+
34697
+ // src/lib/permanent-deletions.ts
34698
+ import { createHash as createHash3, randomUUID } from "node:crypto";
34699
+ import {
34700
+ chmodSync,
34701
+ closeSync as closeSync4,
34702
+ constants as constants2,
34703
+ existsSync as existsSync4,
34704
+ fsyncSync as fsyncSync2,
34705
+ fstatSync as fstatSync2,
34706
+ lstatSync as lstatSync4,
34707
+ mkdirSync as mkdirSync11,
34708
+ openSync as openSync4,
34709
+ readFileSync as readFileSync16,
34710
+ renameSync as renameSync8,
34711
+ unlinkSync as unlinkSync8,
34712
+ writeSync as writeSync2
34713
+ } from "node:fs";
34714
+ import { dirname as dirname10, join as join13 } from "node:path";
34715
+ var PERMANENT_DELETE_KINDS = ["document", "workspace_file"];
34716
+ var UUID = exports_external.string().uuid();
34717
+ var SHA256 = exports_external.string().regex(/^[0-9a-f]{64}$/);
34718
+ function canonicalLogicalKey(kind, logicalKey) {
34719
+ return kind === "document" ? isValidWorkspaceSlug(logicalKey) : validateFilesRelPath(logicalKey) === logicalKey;
34720
+ }
34721
+ var PendingOperationSchema = exports_external.object({
34722
+ operation_id: UUID,
34723
+ payload_digest: SHA256,
34724
+ state: exports_external.enum(["pending", "transport_unknown", "finalized", "terminal_cleaned"]),
34725
+ row_id: UUID.optional(),
34726
+ updated_at: exports_external.string().datetime({ offset: true })
34727
+ }).superRefine((value, ctx) => {
34728
+ if (value.state === "finalized" && value.row_id === undefined) {
34729
+ ctx.addIssue({ code: "custom", message: "finalized operation requires row_id" });
34730
+ }
34731
+ if (value.state !== "finalized" && value.row_id !== undefined) {
34732
+ ctx.addIssue({ code: "custom", message: "row_id is valid only for finalized operations" });
34733
+ }
34734
+ });
34735
+ var RetirementRecordSchema = exports_external.object({
34736
+ kind: exports_external.enum(PERMANENT_DELETE_KINDS),
34737
+ logical_key: exports_external.string().min(1),
34738
+ row_id: UUID,
34739
+ purge_generation: UUID,
34740
+ retirement_sequence: exports_external.number().int().positive().max(Number.MAX_SAFE_INTEGER),
34741
+ retired_at: exports_external.string().datetime({ offset: true }),
34742
+ disposition: exports_external.enum(["pending", "complete"]),
34743
+ completed_at: exports_external.string().datetime({ offset: true }).optional(),
34744
+ stash_path: exports_external.string().min(1).optional(),
34745
+ stash_hash: SHA256.optional(),
34746
+ operation: PendingOperationSchema.optional()
34747
+ }).superRefine((value, ctx) => {
34748
+ if (!canonicalLogicalKey(value.kind, value.logical_key)) {
34749
+ ctx.addIssue({ code: "custom", message: "retirement logical key is not canonical" });
34750
+ }
34751
+ if (value.disposition === "complete" && value.completed_at === undefined) {
34752
+ ctx.addIssue({ code: "custom", message: "complete retirement requires completed_at" });
34753
+ }
34754
+ if (value.disposition === "pending" && value.completed_at !== undefined) {
34755
+ ctx.addIssue({ code: "custom", message: "pending retirement cannot have completed_at" });
34756
+ }
34757
+ if (value.stash_path === undefined !== (value.stash_hash === undefined)) {
34758
+ ctx.addIssue({ code: "custom", message: "stash_path and stash_hash must be written together" });
34759
+ }
34760
+ });
34761
+ var RefreshMarkerSchema = exports_external.object({
34762
+ kind: exports_external.enum(PERMANENT_DELETE_KINDS),
34763
+ logical_key: exports_external.string().min(1),
34764
+ required_generation: UUID.optional(),
34765
+ started_at: exports_external.string().datetime({ offset: true }),
34766
+ attempts: exports_external.number().int().nonnegative(),
34767
+ retry_after: exports_external.string().datetime({ offset: true }).optional(),
34768
+ state: exports_external.enum(["required", "retention_missing"])
34769
+ }).superRefine((value, ctx) => {
34770
+ if (!canonicalLogicalKey(value.kind, value.logical_key)) {
34771
+ ctx.addIssue({ code: "custom", message: "refresh logical key is not canonical" });
34772
+ }
34773
+ });
34774
+ var JournalBodySchema = exports_external.object({
34775
+ version: exports_external.literal(1),
34776
+ revision: exports_external.number().int().nonnegative(),
34777
+ workspace_slug: exports_external.string().min(1),
34778
+ records: exports_external.array(RetirementRecordSchema),
34779
+ refresh: exports_external.array(RefreshMarkerSchema)
34780
+ });
34781
+ var JournalFileSchema = JournalBodySchema.extend({ checksum: SHA256 });
34782
+
34783
+ class PermanentDeletionJournalError extends Error {
34784
+ stage;
34785
+ constructor(message, stage) {
34786
+ super(message);
34787
+ this.stage = stage;
34788
+ this.name = "PermanentDeletionJournalError";
34789
+ }
34790
+ }
34791
+ function permanentDeletionJournalPath(workspaceSlug) {
34792
+ if (!isValidWorkspaceSlug(workspaceSlug)) {
34793
+ throw new PermanentDeletionJournalError(`refusing unsafe workspace slug: ${JSON.stringify(workspaceSlug)}`, "validate");
34794
+ }
34795
+ return join13(plinthHome(), workspaceSlug, "permanent-deletions.json");
34796
+ }
34797
+ function recordKey2(kind, logicalKey) {
34798
+ return `${kind}\x00${logicalKey}`;
34799
+ }
34800
+ function canonical(value) {
34801
+ if (value === null || typeof value !== "object")
34802
+ return JSON.stringify(value);
34803
+ if (Array.isArray(value))
34804
+ return `[${value.map(canonical).join(",")}]`;
34805
+ const obj = value;
34806
+ return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${canonical(obj[key])}`).join(",")}}`;
34807
+ }
34808
+ function canonicalPayloadDigest(value) {
34809
+ return createHash3("sha256").update(canonical(value), "utf8").digest("hex");
34810
+ }
34811
+ function fileCreatePayloadDigest(contentHash, mimeType) {
34812
+ return canonicalPayloadDigest({ content_hash: contentHash, mime_type: mimeType });
34813
+ }
34814
+ function checksum(body) {
34815
+ return createHash3("sha256").update(canonical(body), "utf8").digest("hex");
34816
+ }
34817
+ function emptyJournal(workspaceSlug) {
34818
+ return { version: 1, revision: 0, workspace_slug: workspaceSlug, records: [], refresh: [] };
34819
+ }
34820
+ function validateUniqueness(journal) {
34821
+ const records = new Set;
34822
+ for (const record2 of journal.records) {
34823
+ const key = recordKey2(record2.kind, record2.logical_key);
34824
+ if (records.has(key)) {
34825
+ throw new PermanentDeletionJournalError(`permanent deletion journal contains duplicate record ${record2.kind}:${record2.logical_key}`, "validate");
34826
+ }
34827
+ records.add(key);
34828
+ }
34829
+ const refresh = new Set;
34830
+ for (const marker of journal.refresh) {
34831
+ const key = recordKey2(marker.kind, marker.logical_key);
34832
+ if (refresh.has(key)) {
34833
+ throw new PermanentDeletionJournalError(`permanent deletion journal contains duplicate refresh marker ${marker.kind}:${marker.logical_key}`, "validate");
34834
+ }
34835
+ refresh.add(key);
34836
+ }
34837
+ }
34838
+ function assertOwnerOnlyFile(path5) {
34839
+ const stat = lstatSync4(path5);
34840
+ if (!stat.isFile() || stat.isSymbolicLink()) {
34841
+ throw new PermanentDeletionJournalError(`${path5} is not a regular file`, "validate");
34842
+ }
34843
+ if ((stat.mode & 511) !== 384) {
34844
+ throw new PermanentDeletionJournalError(`${path5} must have mode 0600`, "validate");
34845
+ }
34846
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
34847
+ if (uid !== undefined && stat.uid !== uid) {
34848
+ throw new PermanentDeletionJournalError(`${path5} is not owned by the current user`, "validate");
34849
+ }
34850
+ }
34851
+ function assertPrivateDirectory(path5) {
34852
+ const stat = lstatSync4(path5);
34853
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
34854
+ throw new PermanentDeletionJournalError(`${path5} is not a private directory`, "validate");
34855
+ }
34856
+ if ((stat.mode & 511) !== 448) {
34857
+ throw new PermanentDeletionJournalError(`${path5} must have mode 0700`, "validate");
34858
+ }
34859
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
34860
+ if (uid !== undefined && stat.uid !== uid) {
34861
+ throw new PermanentDeletionJournalError(`${path5} is not owned by the current user`, "validate");
34862
+ }
34863
+ }
34864
+ function parseJournal(workspaceSlug, raw) {
34865
+ let json2;
34866
+ try {
34867
+ json2 = JSON.parse(raw);
34868
+ } catch (error52) {
34869
+ const detail = error52 instanceof Error ? error52.message : String(error52);
34870
+ throw new PermanentDeletionJournalError(`permanent deletion journal is not valid JSON: ${detail}`, "validate");
34871
+ }
34872
+ const parsed = JournalFileSchema.safeParse(json2);
34873
+ if (!parsed.success) {
34874
+ throw new PermanentDeletionJournalError(`permanent deletion journal does not match schema v1: ${parsed.error.message}`, "validate");
34875
+ }
34876
+ const { checksum: storedChecksum, ...body } = parsed.data;
34877
+ if (body.workspace_slug !== workspaceSlug) {
34878
+ throw new PermanentDeletionJournalError(`permanent deletion journal belongs to ${body.workspace_slug}, not ${workspaceSlug}`, "validate");
34879
+ }
34880
+ if (checksum(body) !== storedChecksum) {
34881
+ throw new PermanentDeletionJournalError("permanent deletion journal checksum mismatch", "validate");
34882
+ }
34883
+ validateUniqueness(body);
34884
+ return body;
34885
+ }
34886
+ function fsyncDirectory(path5) {
34887
+ const fd = openSync4(path5, constants2.O_RDONLY);
34888
+ try {
34889
+ fsyncSync2(fd);
34890
+ } finally {
34891
+ closeSync4(fd);
34892
+ }
34893
+ }
34894
+ function loadPermanentDeletionJournal(workspaceSlug) {
34895
+ const target = permanentDeletionJournalPath(workspaceSlug);
34896
+ if (!existsSync4(target))
34897
+ return emptyJournal(workspaceSlug);
34898
+ assertPrivateDirectory(dirname10(target));
34899
+ assertOwnerOnlyFile(target);
34900
+ const journal = parseJournal(workspaceSlug, readFileSync16(target, "utf8"));
34901
+ try {
34902
+ fsyncDirectory(dirname10(target));
34903
+ } catch (error52) {
34904
+ const detail = error52 instanceof Error ? error52.message : String(error52);
34905
+ throw new PermanentDeletionJournalError(`permanent deletion journal durability remains indeterminate: ${detail}`, "durability_indeterminate");
34906
+ }
34907
+ return journal;
34908
+ }
34909
+ function validatedCurrentRevision(workspaceSlug, expected) {
34910
+ const current = loadPermanentDeletionJournal(workspaceSlug);
34911
+ if (current.revision !== expected) {
34912
+ throw new PermanentDeletionJournalError(`permanent deletion journal revision changed from ${expected} to ${current.revision}`, "validate");
34913
+ }
34914
+ return current;
34915
+ }
34916
+ function writePermanentDeletionJournal(prior, next) {
34917
+ if (prior.workspace_slug !== next.workspace_slug) {
34918
+ throw new PermanentDeletionJournalError("journal workspace cannot change", "validate");
34919
+ }
34920
+ validatedCurrentRevision(prior.workspace_slug, prior.revision);
34921
+ const body = JournalBodySchema.parse({ ...next, revision: prior.revision + 1 });
34922
+ validateUniqueness(body);
34923
+ const target = permanentDeletionJournalPath(body.workspace_slug);
34924
+ const parent = dirname10(target);
34925
+ mkdirSync11(parent, { recursive: true, mode: 448 });
34926
+ chmodSync(parent, 448);
34927
+ assertPrivateDirectory(parent);
34928
+ if (existsSync4(target))
34929
+ assertOwnerOnlyFile(target);
34930
+ const tmp = join13(parent, `.permanent-deletions.${process.pid}.${randomUUID()}.tmp`);
34931
+ const wire = JSON.stringify({ ...body, checksum: checksum(body) }, null, 2) + `
34932
+ `;
34933
+ let fd;
34934
+ try {
34935
+ fd = openSync4(tmp, constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | (constants2.O_NOFOLLOW ?? 0), 384);
34936
+ const stat = fstatSync2(fd);
34937
+ if (!stat.isFile() || (stat.mode & 511) !== 384) {
34938
+ throw new PermanentDeletionJournalError("journal temporary file is not owner-only", "prepare");
34939
+ }
34940
+ const bytes = Buffer.from(wire, "utf8");
34941
+ let offset = 0;
34942
+ while (offset < bytes.length)
34943
+ offset += writeSync2(fd, bytes, offset, bytes.length - offset);
34944
+ fsyncSync2(fd);
34945
+ closeSync4(fd);
34946
+ fd = undefined;
34947
+ } catch (error52) {
34948
+ if (fd !== undefined) {
34949
+ try {
34950
+ closeSync4(fd);
34951
+ } catch {}
34952
+ }
34953
+ try {
34954
+ unlinkSync8(tmp);
34955
+ } catch {}
34956
+ if (error52 instanceof PermanentDeletionJournalError)
34957
+ throw error52;
34958
+ const detail = error52 instanceof Error ? error52.message : String(error52);
34959
+ throw new PermanentDeletionJournalError(`could not prepare journal write: ${detail}`, "write");
34960
+ }
34961
+ try {
34962
+ renameSync8(tmp, target);
34963
+ } catch (error52) {
34964
+ let installed2 = false;
34965
+ try {
34966
+ assertOwnerOnlyFile(target);
34967
+ const candidate = parseJournal(body.workspace_slug, readFileSync16(target, "utf8"));
34968
+ installed2 = candidate.revision === body.revision && checksum(candidate) === checksum(body);
34969
+ } catch {
34970
+ installed2 = false;
34971
+ }
34972
+ try {
34973
+ unlinkSync8(tmp);
34974
+ } catch {}
34975
+ if (!installed2) {
34976
+ const detail = error52 instanceof Error ? error52.message : String(error52);
34977
+ throw new PermanentDeletionJournalError(`could not rename journal: ${detail}`, "rename");
34978
+ }
34979
+ }
34980
+ assertOwnerOnlyFile(target);
34981
+ const installed = parseJournal(body.workspace_slug, readFileSync16(target, "utf8"));
34982
+ if (installed.revision !== body.revision || checksum(installed) !== checksum(body)) {
34983
+ throw new PermanentDeletionJournalError("renamed journal revision did not validate", "durability_indeterminate");
34984
+ }
34985
+ try {
34986
+ fsyncDirectory(parent);
34987
+ } catch (firstError) {
34988
+ assertOwnerOnlyFile(target);
34989
+ const reopened = parseJournal(body.workspace_slug, readFileSync16(target, "utf8"));
34990
+ if (reopened.revision !== body.revision || checksum(reopened) !== checksum(body)) {
34991
+ throw new PermanentDeletionJournalError("journal changed after rename", "durability_indeterminate");
34992
+ }
34993
+ try {
34994
+ fsyncDirectory(parent);
34995
+ } catch (secondError) {
34996
+ const first = firstError instanceof Error ? firstError.message : String(firstError);
34997
+ const second = secondError instanceof Error ? secondError.message : String(secondError);
34998
+ throw new PermanentDeletionJournalError(`journal renamed but parent directory sync failed (${first}; retry: ${second})`, "durability_indeterminate");
34999
+ }
35000
+ }
35001
+ return body;
35002
+ }
35003
+ function updatePermanentDeletionJournal(workspaceSlug, update) {
35004
+ const prior = loadPermanentDeletionJournal(workspaceSlug);
35005
+ const next = update(prior);
35006
+ if (next === prior)
35007
+ return prior;
35008
+ return writePermanentDeletionJournal(prior, next);
35009
+ }
35010
+ function findRetirement(journal, kind, logicalKey) {
35011
+ return journal.records.find((record2) => record2.kind === kind && record2.logical_key === logicalKey);
35012
+ }
35013
+ function findRefreshMarker(journal, kind, logicalKey) {
35014
+ return journal.refresh.find((marker) => marker.kind === kind && marker.logical_key === logicalKey);
35015
+ }
35016
+ function replaceRecord(journal, record2) {
35017
+ return {
35018
+ ...journal,
35019
+ records: [
35020
+ ...journal.records.filter((item) => recordKey2(item.kind, item.logical_key) !== recordKey2(record2.kind, record2.logical_key)),
35021
+ record2
35022
+ ]
35023
+ };
35024
+ }
35025
+ function requireFeedRefresh(args) {
35026
+ const now = args.now ?? (() => new Date);
35027
+ return updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35028
+ const existing = findRefreshMarker(journal, args.kind, args.logicalKey);
35029
+ if (existing)
35030
+ return journal;
35031
+ return {
35032
+ ...journal,
35033
+ refresh: [...journal.refresh, {
35034
+ kind: args.kind,
35035
+ logical_key: args.logicalKey,
35036
+ ...args.requiredGeneration ? { required_generation: args.requiredGeneration } : {},
35037
+ started_at: now().toISOString(),
35038
+ attempts: 0,
35039
+ state: "required"
35040
+ }]
35041
+ };
35042
+ });
35043
+ }
35044
+ function markRetentionMissing(args) {
35045
+ const now = args.now ?? (() => new Date);
35046
+ return updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35047
+ const existing = findRefreshMarker(journal, args.kind, args.logicalKey);
35048
+ if (!existing)
35049
+ return journal;
35050
+ const attempts = existing.attempts + 1;
35051
+ const backoffMs = Math.min(3600000, 1000 * 2 ** Math.min(attempts, 12));
35052
+ return {
35053
+ ...journal,
35054
+ refresh: journal.refresh.map((marker) => marker === existing ? {
35055
+ ...marker,
35056
+ attempts,
35057
+ state: "retention_missing",
35058
+ retry_after: new Date(now().getTime() + backoffMs).toISOString()
35059
+ } : marker)
35060
+ };
35061
+ });
35062
+ }
35063
+ function clearFeedRefresh(args) {
35064
+ return updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35065
+ const record2 = findRetirement(journal, args.kind, args.logicalKey);
35066
+ if (!record2 || record2.disposition !== "complete" || record2.purge_generation !== args.generation) {
35067
+ throw new PermanentDeletionJournalError("cannot clear refresh before matching disposition is complete", "validate");
35068
+ }
35069
+ const next = journal.refresh.filter((marker) => recordKey2(marker.kind, marker.logical_key) !== recordKey2(args.kind, args.logicalKey));
35070
+ return next.length === journal.refresh.length ? journal : { ...journal, refresh: next };
35071
+ });
35072
+ }
35073
+ function recordRetirementPending(args) {
35074
+ return updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35075
+ const existing = findRetirement(journal, args.kind, args.logicalKey);
35076
+ if (existing && existing.retirement_sequence > args.retirementSequence)
35077
+ return journal;
35078
+ if (existing && existing.retirement_sequence === args.retirementSequence && existing.purge_generation !== args.purgeGeneration) {
35079
+ throw new PermanentDeletionJournalError("one retirement_sequence maps to multiple generations", "validate");
35080
+ }
35081
+ if (existing?.purge_generation === args.purgeGeneration)
35082
+ return journal;
35083
+ return replaceRecord(journal, {
35084
+ kind: args.kind,
35085
+ logical_key: args.logicalKey,
35086
+ row_id: args.rowId,
35087
+ purge_generation: args.purgeGeneration,
35088
+ retirement_sequence: args.retirementSequence,
35089
+ retired_at: args.retiredAt,
35090
+ disposition: "pending"
35091
+ });
35092
+ });
35093
+ }
35094
+ function completeRetirement(args) {
35095
+ if (args.stashPath === undefined !== (args.stashHash === undefined)) {
35096
+ throw new PermanentDeletionJournalError("stash path and hash must be supplied together", "validate");
35097
+ }
35098
+ const now = args.now ?? (() => new Date);
35099
+ return updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35100
+ const existing = findRetirement(journal, args.kind, args.logicalKey);
35101
+ if (!existing || existing.purge_generation !== args.generation) {
35102
+ throw new PermanentDeletionJournalError("cannot complete an unknown retirement generation", "validate");
35103
+ }
35104
+ if (existing.disposition === "complete")
35105
+ return journal;
35106
+ return replaceRecord(journal, {
35107
+ ...existing,
35108
+ disposition: "complete",
35109
+ completed_at: now().toISOString(),
35110
+ ...args.stashPath ? { stash_path: args.stashPath, stash_hash: args.stashHash } : {}
35111
+ });
35112
+ });
35113
+ }
35114
+ function recordRetirementStash(args) {
35115
+ return updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35116
+ const existing = findRetirement(journal, args.kind, args.logicalKey);
35117
+ if (!existing || existing.purge_generation !== args.generation) {
35118
+ throw new PermanentDeletionJournalError("cannot attach a stash to an unknown retirement", "validate");
35119
+ }
35120
+ if (existing.stash_path === args.stashPath && existing.stash_hash === args.stashHash) {
35121
+ return journal;
35122
+ }
35123
+ if (existing.stash_path && existing.stash_hash !== args.stashHash) {
35124
+ throw new PermanentDeletionJournalError("retirement stash changed after it was recorded", "validate");
35125
+ }
35126
+ return replaceRecord(journal, {
35127
+ ...existing,
35128
+ stash_path: args.stashPath,
35129
+ stash_hash: args.stashHash
35130
+ });
35131
+ });
35132
+ }
35133
+ function prepareGenerationCreate(args) {
35134
+ const now = args.now ?? (() => new Date);
35135
+ let decision = { kind: "ordinary" };
35136
+ updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35137
+ if (findRefreshMarker(journal, args.kind, args.logicalKey)) {
35138
+ decision = { kind: "blocked", reason: "refresh_required" };
35139
+ return journal;
35140
+ }
35141
+ const retirement = findRetirement(journal, args.kind, args.logicalKey);
35142
+ if (!retirement) {
35143
+ decision = { kind: "ordinary" };
35144
+ return journal;
35145
+ }
35146
+ if (retirement.disposition !== "complete") {
35147
+ decision = { kind: "blocked", reason: "disposition_pending" };
35148
+ return journal;
35149
+ }
35150
+ const operation = retirement.operation;
35151
+ if (operation) {
35152
+ if (operation.payload_digest === args.payloadDigest && (operation.state === "pending" || operation.state === "transport_unknown")) {
35153
+ decision = {
35154
+ kind: "authorised",
35155
+ retiredGeneration: retirement.purge_generation,
35156
+ operationId: operation.operation_id,
35157
+ payloadDigest: operation.payload_digest
35158
+ };
35159
+ return journal;
35160
+ }
35161
+ if (operation.state !== "terminal_cleaned") {
35162
+ decision = { kind: "blocked", reason: "operation_unresolved" };
35163
+ return journal;
35164
+ }
35165
+ }
35166
+ const nextOperation = {
35167
+ operation_id: randomUUID(),
35168
+ payload_digest: args.payloadDigest,
35169
+ state: "pending",
35170
+ updated_at: now().toISOString()
35171
+ };
35172
+ decision = {
35173
+ kind: "authorised",
35174
+ retiredGeneration: retirement.purge_generation,
35175
+ operationId: nextOperation.operation_id,
35176
+ payloadDigest: nextOperation.payload_digest
35177
+ };
35178
+ return replaceRecord(journal, { ...retirement, operation: nextOperation });
35179
+ });
35180
+ return decision;
35181
+ }
35182
+ function updateCreateOperation(args) {
35183
+ const now = args.now ?? (() => new Date);
35184
+ return updatePermanentDeletionJournal(args.workspaceSlug, (journal) => {
35185
+ const retirement = findRetirement(journal, args.kind, args.logicalKey);
35186
+ if (!retirement?.operation || retirement.operation.operation_id !== args.operationId) {
35187
+ throw new PermanentDeletionJournalError("operation update does not match the journal", "validate");
35188
+ }
35189
+ const from = retirement.operation.state;
35190
+ const allowed = {
35191
+ pending: new Set(["pending", "transport_unknown", "finalized", "terminal_cleaned"]),
35192
+ transport_unknown: new Set(["transport_unknown", "pending", "finalized", "terminal_cleaned"]),
35193
+ finalized: new Set(["finalized", "terminal_cleaned"]),
35194
+ terminal_cleaned: new Set(["terminal_cleaned"])
35195
+ };
35196
+ if (!allowed[from].has(args.state)) {
35197
+ throw new PermanentDeletionJournalError(`invalid create operation transition ${from} -> ${args.state}`, "validate");
35198
+ }
35199
+ if (args.state === "finalized" && args.rowId === undefined) {
35200
+ throw new PermanentDeletionJournalError("finalized operation requires a row id", "validate");
35201
+ }
35202
+ return replaceRecord(journal, {
35203
+ ...retirement,
35204
+ operation: {
35205
+ operation_id: retirement.operation.operation_id,
35206
+ payload_digest: retirement.operation.payload_digest,
35207
+ state: args.state,
35208
+ ...args.state === "finalized" && args.rowId ? { row_id: args.rowId } : {},
35209
+ updated_at: now().toISOString()
35210
+ }
35211
+ });
35212
+ });
35213
+ }
35214
+
35215
+ // src/lib/permanent-mutex.ts
35216
+ import { AsyncLocalStorage } from "node:async_hooks";
35217
+
35218
+ // src/lib/per-id-mutex.ts
35219
+ class PerIdMutex {
35220
+ chains = new Map;
35221
+ async run(key, fn) {
35222
+ const prior = this.chains.get(key) ?? Promise.resolve();
35223
+ const next = prior.then(fn, fn);
35224
+ this.chains.set(key, next);
35225
+ const cleanup = () => {
35226
+ if (this.chains.get(key) === next) {
35227
+ this.chains.delete(key);
35228
+ }
35229
+ };
35230
+ next.then(cleanup, cleanup);
35231
+ return next;
35232
+ }
35233
+ size() {
35234
+ return this.chains.size;
35235
+ }
35236
+ }
35237
+
35238
+ // src/lib/permanent-mutex.ts
35239
+ var mutex = new PerIdMutex;
35240
+ var heldKeys = new AsyncLocalStorage;
35241
+ function permanentDeletionMutexKey(kind, logicalKey) {
35242
+ return `permanent:${kind}:${logicalKey}`;
35243
+ }
35244
+ function withPermanentDeletionLock(kind, logicalKey, run) {
35245
+ const key = permanentDeletionMutexKey(kind, logicalKey);
35246
+ const held = heldKeys.getStore();
35247
+ if (held?.has(key))
35248
+ return run();
35249
+ return mutex.run(key, () => heldKeys.run(new Set([...held ?? [], key]), run));
35250
+ }
35251
+
35252
+ // src/lib/permanent-create.ts
35253
+ async function prepareOrResolveGenerationCreate(args) {
35254
+ return withPermanentDeletionLock(args.kind, args.logicalKey, () => prepareOrResolveGenerationCreateLocked(args));
35255
+ }
35256
+ async function prepareOrResolveGenerationCreateLocked(args) {
35257
+ let decision = prepareGenerationCreate(args);
35258
+ if (decision.kind !== "blocked" || decision.reason !== "operation_unresolved") {
35259
+ return decision;
35260
+ }
35261
+ const journal = loadPermanentDeletionJournal(args.workspaceSlug);
35262
+ const retirement = findRetirement(journal, args.kind, args.logicalKey);
35263
+ const operation = retirement?.operation;
35264
+ if (!operation)
35265
+ return decision;
35266
+ const entityKind = args.kind;
35267
+ const resolved = await resolvePermanentDeletionOperation({
35268
+ baseUrl: args.baseUrl,
35269
+ pat: args.pat,
35270
+ entityKind,
35271
+ operationId: operation.operation_id,
35272
+ fetchImpl: args.fetchImpl
35273
+ });
35274
+ if (resolved.entity_kind !== entityKind || resolved.operation_id !== operation.operation_id) {
35275
+ throw new Error("operation resolution identity does not match the local journal");
35276
+ }
35277
+ if (resolved.status === "retired_not_found") {
35278
+ updateCreateOperation({
35279
+ workspaceSlug: args.workspaceSlug,
35280
+ kind: args.kind,
35281
+ logicalKey: args.logicalKey,
35282
+ operationId: operation.operation_id,
35283
+ state: "terminal_cleaned",
35284
+ now: args.now
35285
+ });
35286
+ return prepareGenerationCreate(args);
35287
+ }
35288
+ if (resolved.logical_key !== args.logicalKey || resolved.payload_digest !== operation.payload_digest) {
35289
+ throw new Error("operation resolution binding does not match the local journal");
35290
+ }
35291
+ if (resolved.status === "pending") {
35292
+ updateCreateOperation({
35293
+ workspaceSlug: args.workspaceSlug,
35294
+ kind: args.kind,
35295
+ logicalKey: args.logicalKey,
35296
+ operationId: operation.operation_id,
35297
+ state: "pending",
35298
+ now: args.now
35299
+ });
35300
+ return { kind: "blocked", reason: "operation_unresolved" };
35301
+ }
35302
+ if (resolved.status === "terminal_cleaned") {
35303
+ updateCreateOperation({
35304
+ workspaceSlug: args.workspaceSlug,
35305
+ kind: args.kind,
35306
+ logicalKey: args.logicalKey,
35307
+ operationId: operation.operation_id,
35308
+ state: "terminal_cleaned",
35309
+ now: args.now
35310
+ });
35311
+ return prepareGenerationCreate(args);
35312
+ }
35313
+ const id = exports_external.object({ id: exports_external.string().uuid() }).parse(resolved.live).id;
35314
+ updateCreateOperation({
35315
+ workspaceSlug: args.workspaceSlug,
35316
+ kind: args.kind,
35317
+ logicalKey: args.logicalKey,
35318
+ operationId: operation.operation_id,
35319
+ state: "finalized",
35320
+ rowId: id,
35321
+ now: args.now
35322
+ });
35323
+ return {
35324
+ kind: "finalized",
35325
+ live: resolved.live,
35326
+ operationId: operation.operation_id,
35327
+ payloadDigest: operation.payload_digest
35328
+ };
35329
+ }
34452
35330
 
34453
35331
  // src/lib/files/content-hash.ts
34454
- import { createHash as createHash3 } from "node:crypto";
35332
+ import { createHash as createHash4 } from "node:crypto";
34455
35333
  function sha256Bytes(bytes) {
34456
- return createHash3("sha256").update(bytes).digest("hex");
35334
+ return createHash4("sha256").update(bytes).digest("hex");
34457
35335
  }
34458
35336
 
34459
35337
  // src/lib/files/mime.ts
@@ -34552,6 +35430,9 @@ async function restoreFileFromCloud(args) {
34552
35430
  });
34553
35431
  }
34554
35432
  async function pushFilesUpsert(args) {
35433
+ return withPermanentDeletionLock("workspace_file", args.relPath, () => pushFilesUpsertLocked(args));
35434
+ }
35435
+ async function pushFilesUpsertLocked(args) {
34555
35436
  const { workspaceSlug, baseUrl, pat, absolutePath, relPath, state, prepared, deps } = args;
34556
35437
  const fetchImpl = deps?.fetchImpl;
34557
35438
  const now = deps?.now ?? (() => new Date);
@@ -34604,7 +35485,7 @@ async function pushFilesUpsert(args) {
34604
35485
  state: next
34605
35486
  };
34606
35487
  }
34607
- bytes = readFileSync16(absolutePath);
35488
+ bytes = readFileSync17(absolutePath);
34608
35489
  hash2 = sha256Bytes(bytes);
34609
35490
  inode = st.ino;
34610
35491
  }
@@ -34619,7 +35500,61 @@ async function pushFilesUpsert(args) {
34619
35500
  return { outcome: { kind: "noop", relPath }, state };
34620
35501
  }
34621
35502
  const mimeType = inferMime(relPath);
35503
+ const createProtocol = existing ? { kind: "ordinary" } : await prepareOrResolveGenerationCreate({
35504
+ workspaceSlug,
35505
+ baseUrl,
35506
+ pat,
35507
+ kind: "workspace_file",
35508
+ logicalKey: relPath,
35509
+ payloadDigest: fileCreatePayloadDigest(hash2, mimeType),
35510
+ now,
35511
+ fetchImpl
35512
+ });
35513
+ if (createProtocol.kind === "blocked") {
35514
+ output?.warn(`files/${relPath} is held by permanent-delete reconciliation (${createProtocol.reason}).`);
35515
+ return {
35516
+ outcome: { kind: "permanent_delete_blocked", relPath, reason: createProtocol.reason },
35517
+ state
35518
+ };
35519
+ }
35520
+ if (createProtocol.kind === "finalized") {
35521
+ const parsed = FileLiveSchema.safeParse(createProtocol.live);
35522
+ if (!parsed.success) {
35523
+ throw new ApiError(200, "finalized file create operation live projection did not match the file schema");
35524
+ }
35525
+ if (parsed.data.path !== relPath) {
35526
+ throw new ApiError(200, "finalized file create operation live projection had the wrong path");
35527
+ }
35528
+ const adoptedState = persistFiles(state, {
35529
+ id: parsed.data.id,
35530
+ path: relPath,
35531
+ content_hash: parsed.data.content_hash,
35532
+ size_bytes: parsed.data.size_bytes,
35533
+ mime_type: parsed.data.mime_type,
35534
+ updated_at: parsed.data.updated_at,
35535
+ inode
35536
+ });
35537
+ return pushFilesUpsert({
35538
+ ...args,
35539
+ state: adoptedState,
35540
+ prepared: { bytes, hash: hash2, size, inode }
35541
+ });
35542
+ }
34622
35543
  const acceptPush = (row, action) => {
35544
+ if (createProtocol.kind === "authorised" && row.path !== relPath) {
35545
+ throw new ApiError(200, "generation-bearing file create response had the wrong path");
35546
+ }
35547
+ if (createProtocol.kind === "authorised") {
35548
+ updateCreateOperation({
35549
+ workspaceSlug,
35550
+ kind: "workspace_file",
35551
+ logicalKey: relPath,
35552
+ operationId: createProtocol.operationId,
35553
+ state: "finalized",
35554
+ rowId: row.id,
35555
+ now
35556
+ });
35557
+ }
34623
35558
  output?.log(filePushLine({ path: relPath, id: row.id, action }));
34624
35559
  return {
34625
35560
  outcome: { kind: action, relPath, id: row.id },
@@ -34655,22 +35590,57 @@ async function pushFilesUpsert(args) {
34655
35590
  output?.log(fileFailedLine({ path: relPath, status: 400, reason: "hash_mismatch" }));
34656
35591
  return { outcome: { kind: "ignored", relPath, reason: "hash_mismatch" }, state };
34657
35592
  };
34658
- const res = await postFile({
34659
- baseUrl,
34660
- pat,
34661
- relPath,
34662
- bytes,
34663
- contentHash: hash2,
34664
- mimeType,
34665
- expectedUpdatedAt: existing?.updated_at ?? null,
34666
- fetchImpl
34667
- });
35593
+ let res;
35594
+ try {
35595
+ res = await postFile({
35596
+ baseUrl,
35597
+ pat,
35598
+ relPath,
35599
+ bytes,
35600
+ contentHash: hash2,
35601
+ mimeType,
35602
+ expectedUpdatedAt: existing?.updated_at ?? null,
35603
+ ...createProtocol.kind === "authorised" ? {
35604
+ retiredGeneration: createProtocol.retiredGeneration,
35605
+ operationId: createProtocol.operationId
35606
+ } : {},
35607
+ fetchImpl
35608
+ });
35609
+ } catch (error52) {
35610
+ if (createProtocol.kind === "authorised" && (!(error52 instanceof ApiError) || error52.status === 0 || error52.status >= 500 || error52.status >= 200 && error52.status < 300)) {
35611
+ updateCreateOperation({
35612
+ workspaceSlug,
35613
+ kind: "workspace_file",
35614
+ logicalKey: relPath,
35615
+ operationId: createProtocol.operationId,
35616
+ state: "transport_unknown",
35617
+ now
35618
+ });
35619
+ }
35620
+ throw error52;
35621
+ }
34668
35622
  if (res.ok)
34669
35623
  return acceptPush(res.row, existing ? "updated" : "created");
34670
35624
  if (res.code === 413)
34671
35625
  return rejectOverCap(res.maxBytes);
34672
35626
  if (res.code === 400)
34673
35627
  return rejectHashMismatch();
35628
+ if ("reason" in res && res.reason === "permanent_name_retired") {
35629
+ if (res.entityKind !== "workspace_file" || res.logicalKey !== relPath) {
35630
+ throw new ApiError(409, "permanent-name refusal identity did not match the file create");
35631
+ }
35632
+ requireFeedRefresh({
35633
+ workspaceSlug,
35634
+ kind: "workspace_file",
35635
+ logicalKey: res.logicalKey,
35636
+ now
35637
+ });
35638
+ output?.warn(`files/${res.logicalKey} was permanently deleted in Plinth; local bytes are held while the file feed is refreshed.`);
35639
+ return {
35640
+ outcome: { kind: "permanent_delete_blocked", relPath: res.logicalKey, reason: "refresh_required" },
35641
+ state
35642
+ };
35643
+ }
34674
35644
  let nextState = state;
34675
35645
  let cloudRow;
34676
35646
  if (existing) {
@@ -34692,6 +35662,25 @@ async function pushFilesUpsert(args) {
34692
35662
  return rejectOverCap(retry.maxBytes);
34693
35663
  if (retry.code === 400)
34694
35664
  return rejectHashMismatch();
35665
+ if ("reason" in retry && retry.reason === "permanent_name_retired") {
35666
+ if (retry.entityKind !== "workspace_file" || retry.logicalKey !== relPath) {
35667
+ throw new ApiError(409, "permanent-name refusal identity did not match the file retry");
35668
+ }
35669
+ requireFeedRefresh({
35670
+ workspaceSlug,
35671
+ kind: "workspace_file",
35672
+ logicalKey: retry.logicalKey,
35673
+ now
35674
+ });
35675
+ return {
35676
+ outcome: {
35677
+ kind: "permanent_delete_blocked",
35678
+ relPath: retry.logicalKey,
35679
+ reason: "refresh_required"
35680
+ },
35681
+ state
35682
+ };
35683
+ }
34695
35684
  cloudRow = undefined;
34696
35685
  }
34697
35686
  }
@@ -34757,29 +35746,9 @@ async function pushFilesDelete(args) {
34757
35746
  return { outcome: { kind: "conflict", relPath, stashPath: "" }, state: nextState };
34758
35747
  }
34759
35748
 
34760
- // src/lib/per-id-mutex.ts
34761
- class PerIdMutex {
34762
- chains = new Map;
34763
- async run(key, fn) {
34764
- const prior = this.chains.get(key) ?? Promise.resolve();
34765
- const next = prior.then(fn, fn);
34766
- this.chains.set(key, next);
34767
- const cleanup = () => {
34768
- if (this.chains.get(key) === next) {
34769
- this.chains.delete(key);
34770
- }
34771
- };
34772
- next.then(cleanup, cleanup);
34773
- return next;
34774
- }
34775
- size() {
34776
- return this.chains.size;
34777
- }
34778
- }
34779
-
34780
35749
  // src/lib/push.ts
34781
35750
  var import_gray_matter10 = __toESM(require_gray_matter(), 1);
34782
- import { existsSync as existsSync4, readFileSync as readFileSync18, statSync as statSync8 } from "node:fs";
35751
+ import { existsSync as existsSync5, readFileSync as readFileSync19, statSync as statSync8 } from "node:fs";
34783
35752
  import { basename as basename5, relative as relative5 } from "node:path";
34784
35753
 
34785
35754
  // src/lib/frontmatter-id.ts
@@ -34829,7 +35798,7 @@ function frontmatterIdWriteIsSound(next, id, expectedBody) {
34829
35798
 
34830
35799
  // src/lib/pull-write.ts
34831
35800
  var import_gray_matter9 = __toESM(require_gray_matter(), 1);
34832
- import { readFileSync as readFileSync17 } from "node:fs";
35801
+ import { readFileSync as readFileSync18 } from "node:fs";
34833
35802
  function computeMirrorHashes(strategy, content) {
34834
35803
  const parsed = import_gray_matter9.default(content);
34835
35804
  const fm = parsed.data ?? {};
@@ -34873,7 +35842,7 @@ function relocateForSlugChange(args) {
34873
35842
  }
34874
35843
  let raw = null;
34875
35844
  try {
34876
- raw = readFileSync17(fromPath, "utf8");
35845
+ raw = readFileSync18(fromPath, "utf8");
34877
35846
  } catch (err) {
34878
35847
  const code = err?.code;
34879
35848
  if (code === "ENOENT") {
@@ -34939,12 +35908,21 @@ function relocateForSlugChange(args) {
34939
35908
  }
34940
35909
  function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor, pathSlug) {
34941
35910
  const targetSlug = pathSlug ?? doc2.slug;
35911
+ const content = renderMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, targetSlug);
35912
+ if (suppressor) {
35913
+ const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
35914
+ suppressor.registerWrite(target, computeMirrorHashes(strategy, content));
35915
+ }
35916
+ writeMirrorFile(workspaceSlug, strategy.type, targetSlug, content);
35917
+ return content;
35918
+ }
35919
+ function renderMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, targetSlug) {
34942
35920
  let content = strategy.serialise(doc2);
34943
35921
  if (strategy.preserveLocalBodyOnPull) {
34944
35922
  const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
34945
35923
  let existing = null;
34946
35924
  try {
34947
- existing = readFileSync17(target, "utf8");
35925
+ existing = readFileSync18(target, "utf8");
34948
35926
  } catch (err) {
34949
35927
  if (err?.code !== "ENOENT")
34950
35928
  throw err;
@@ -34958,11 +35936,6 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor,
34958
35936
  `;
34959
35937
  }
34960
35938
  }
34961
- if (suppressor) {
34962
- const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
34963
- suppressor.registerWrite(target, computeMirrorHashes(strategy, content));
34964
- }
34965
- writeMirrorFile(workspaceSlug, strategy.type, targetSlug, content);
34966
35939
  return content;
34967
35940
  }
34968
35941
 
@@ -35076,7 +36049,7 @@ function stampStateFromMirror(args) {
35076
36049
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
35077
36050
  const targetSlug = args.pathSlug ?? doc2.slug;
35078
36051
  const mirrorPath = entityFilePath(workspaceSlug, strategy.type, targetSlug);
35079
- const mirrorRaw = readFileSync18(mirrorPath, "utf8");
36052
+ const mirrorRaw = readFileSync19(mirrorPath, "utf8");
35080
36053
  const mirrorParsed = import_gray_matter10.default(mirrorRaw);
35081
36054
  const mirrorFm = mirrorParsed.data ?? {};
35082
36055
  const mirrorLifted = splitUserFrontmatter(strategy, mirrorFm);
@@ -35196,7 +36169,7 @@ async function pushFile(args) {
35196
36169
  const identity = args.identity ?? "auto";
35197
36170
  const fetchImpl = deps?.fetchImpl;
35198
36171
  const now = deps?.now ?? (() => new Date);
35199
- const mutex = deps?.mutex ?? new PerIdMutex;
36172
+ const mutex2 = deps?.mutex ?? new PerIdMutex;
35200
36173
  const output = deps?.output;
35201
36174
  const suppressor = deps?.suppressor;
35202
36175
  const echo = deps?.echo;
@@ -35222,14 +36195,14 @@ async function pushFile(args) {
35222
36195
  absolutePath,
35223
36196
  state,
35224
36197
  unlinkContext,
35225
- mutex,
36198
+ mutex: mutex2,
35226
36199
  fetchImpl,
35227
36200
  output
35228
36201
  });
35229
36202
  }
35230
36203
  let raw;
35231
36204
  try {
35232
- raw = readFileSync18(absolutePath, "utf8");
36205
+ raw = readFileSync19(absolutePath, "utf8");
35233
36206
  } catch (err) {
35234
36207
  if (err?.code === "ENOENT") {
35235
36208
  return runPushDelete({
@@ -35242,7 +36215,7 @@ async function pushFile(args) {
35242
36215
  absolutePath,
35243
36216
  state,
35244
36217
  unlinkContext: null,
35245
- mutex,
36218
+ mutex: mutex2,
35246
36219
  fetchImpl,
35247
36220
  output
35248
36221
  });
@@ -35304,7 +36277,7 @@ async function pushFile(args) {
35304
36277
  raw,
35305
36278
  parsed,
35306
36279
  state,
35307
- mutex,
36280
+ mutex: mutex2,
35308
36281
  fetchImpl,
35309
36282
  now,
35310
36283
  output,
@@ -35325,7 +36298,7 @@ async function pushFile(args) {
35325
36298
  parsed,
35326
36299
  stateEntry,
35327
36300
  state,
35328
- mutex,
36301
+ mutex: mutex2,
35329
36302
  fetchImpl,
35330
36303
  now,
35331
36304
  output,
@@ -35579,7 +36552,7 @@ async function runPushUpsert(args) {
35579
36552
  if (liveEntry !== snapshotEntry) {
35580
36553
  let onDisk = null;
35581
36554
  try {
35582
- onDisk = readFileSync18(args.absolutePath, "utf8");
36555
+ onDisk = readFileSync19(args.absolutePath, "utf8");
35583
36556
  } catch {
35584
36557
  onDisk = null;
35585
36558
  }
@@ -35788,6 +36761,28 @@ async function runPushUpsert(args) {
35788
36761
  base: workingState
35789
36762
  };
35790
36763
  }
36764
+ if (patchResult.reason === "permanent_name_retired") {
36765
+ if (strategy.type !== "documents" || patchResult.entityKind !== "document" || patchResult.logicalKey !== args.stateEntry.slug) {
36766
+ throw new ApiError(409, "permanent-name refusal identity did not match the document update");
36767
+ }
36768
+ requireFeedRefresh({
36769
+ workspaceSlug: args.workspaceSlug,
36770
+ kind: "document",
36771
+ logicalKey: patchResult.logicalKey,
36772
+ now: args.now
36773
+ });
36774
+ args.output?.warn(`${strategy.type}/${patchResult.logicalKey} was permanently deleted in Plinth; local content is held while the document feed is refreshed.`);
36775
+ return {
36776
+ outcome: {
36777
+ kind: "permanent_delete_blocked",
36778
+ entityType: strategy.type,
36779
+ entitySlug: patchResult.logicalKey,
36780
+ reason: "refresh_required"
36781
+ },
36782
+ state: workingState,
36783
+ base: workingState
36784
+ };
36785
+ }
35791
36786
  if (patchResult.reason === "slug_conflict") {
35792
36787
  throw new Error(`patch ${strategy.type} returned slug_conflict for a body+frontmatter PATCH (no slug sent)`);
35793
36788
  }
@@ -36019,7 +37014,7 @@ function writeCreatedIdBack(args) {
36019
37014
  if (entityFilePath(args.workspaceSlug, args.strategy.type, args.slug) !== args.absolutePath) {
36020
37015
  return;
36021
37016
  }
36022
- const readBack = args.readBack ?? ((p) => readFileSync18(p, "utf8"));
37017
+ const readBack = args.readBack ?? ((p) => readFileSync19(p, "utf8"));
36023
37018
  if (readBack(args.absolutePath) !== args.raw)
36024
37019
  return;
36025
37020
  args.suppressor?.registerWrite(args.absolutePath, computeMirrorHashes(args.strategy, write.content));
@@ -36027,6 +37022,12 @@ function writeCreatedIdBack(args) {
36027
37022
  } catch {}
36028
37023
  }
36029
37024
  async function runPushCreate(args) {
37025
+ if (args.strategy.type === "documents") {
37026
+ return withPermanentDeletionLock("document", args.slug, () => runPushCreateLocked(args));
37027
+ }
37028
+ return runPushCreateLocked(args);
37029
+ }
37030
+ async function runPushCreateLocked(args) {
36030
37031
  const strategy = args.strategy;
36031
37032
  const key = `create:${strategy.type}:${args.slug}`;
36032
37033
  return args.mutex.run(key, async () => {
@@ -36064,15 +37065,108 @@ async function runPushCreate(args) {
36064
37065
  else
36065
37066
  payload.body = normalised;
36066
37067
  }
36067
- const createResult = await createEntity({
36068
- strategy,
37068
+ const createProtocol = strategy.type === "documents" ? await prepareOrResolveGenerationCreate({
37069
+ workspaceSlug: args.workspaceSlug,
36069
37070
  baseUrl: args.baseUrl,
36070
37071
  pat: args.pat,
36071
- payload,
37072
+ kind: "document",
37073
+ logicalKey: args.slug,
37074
+ payloadDigest: canonicalPayloadDigest(payload),
37075
+ now: args.now,
36072
37076
  fetchImpl: args.fetchImpl
36073
- });
37077
+ }) : { kind: "ordinary" };
37078
+ if (createProtocol.kind === "blocked") {
37079
+ args.output?.warn(`${strategy.type}/${args.slug} is held by permanent-delete reconciliation (${createProtocol.reason}).`);
37080
+ return {
37081
+ outcome: {
37082
+ kind: "permanent_delete_blocked",
37083
+ entityType: strategy.type,
37084
+ entitySlug: args.slug,
37085
+ reason: createProtocol.reason
37086
+ },
37087
+ state: args.state,
37088
+ base: args.state
37089
+ };
37090
+ }
37091
+ if (createProtocol.kind === "finalized") {
37092
+ const parsedLive = strategy.singleResponseSchema.safeParse(createProtocol.live);
37093
+ if (!parsedLive.success) {
37094
+ throw new ApiError(200, "finalized create operation live projection did not match the entity schema");
37095
+ }
37096
+ if (parsedLive.data.slug !== args.slug) {
37097
+ throw new ApiError(200, "finalized create operation live projection had the wrong slug");
37098
+ }
37099
+ const acknowledged = computeMirrorHashes(strategy, strategy.serialise(parsedLive.data));
37100
+ const adoptedEntry = {
37101
+ id: parsedLive.data.id,
37102
+ slug: parsedLive.data.slug,
37103
+ updated_at: parsedLive.data.updated_at,
37104
+ revision: parsedLive.data.revision,
37105
+ body_hash: acknowledged.bodyHash,
37106
+ frontmatter_hash: acknowledged.frontmatterHash,
37107
+ inode: safeInode2(args.absolutePath)
37108
+ };
37109
+ const adoptedState = persistEntry(args.state, strategy.type, adoptedEntry);
37110
+ return runPushUpsert({
37111
+ echo: args.echo,
37112
+ strategy,
37113
+ workspaceSlug: args.workspaceSlug,
37114
+ baseUrl: args.baseUrl,
37115
+ pat: args.pat,
37116
+ absolutePath: args.absolutePath,
37117
+ raw: args.raw,
37118
+ parsed: args.parsed,
37119
+ stateEntry: adoptedEntry,
37120
+ state: adoptedState,
37121
+ mutex: args.mutex,
37122
+ fetchImpl: args.fetchImpl,
37123
+ now: args.now,
37124
+ output: args.output,
37125
+ quarantinedKeys: args.quarantinedKeys
37126
+ });
37127
+ }
37128
+ let createResult;
37129
+ try {
37130
+ createResult = await createEntity({
37131
+ strategy,
37132
+ baseUrl: args.baseUrl,
37133
+ pat: args.pat,
37134
+ payload,
37135
+ ...createProtocol.kind === "authorised" ? {
37136
+ retiredGeneration: createProtocol.retiredGeneration,
37137
+ operationId: createProtocol.operationId
37138
+ } : {},
37139
+ fetchImpl: args.fetchImpl
37140
+ });
37141
+ } catch (error52) {
37142
+ if (createProtocol.kind === "authorised" && (!(error52 instanceof ApiError) || error52.status === 0 || error52.status >= 500 || error52.status >= 200 && error52.status < 300)) {
37143
+ updateCreateOperation({
37144
+ workspaceSlug: args.workspaceSlug,
37145
+ kind: "document",
37146
+ logicalKey: args.slug,
37147
+ operationId: createProtocol.operationId,
37148
+ state: "transport_unknown",
37149
+ now: args.now
37150
+ });
37151
+ }
37152
+ throw error52;
37153
+ }
36074
37154
  if (createResult.ok) {
36075
37155
  const doc2 = createResult.doc;
37156
+ if (createProtocol.kind === "authorised" && doc2.slug !== args.slug) {
37157
+ throw new ApiError(200, "generation-bearing create response had the wrong slug");
37158
+ }
37159
+ if (createProtocol.kind === "authorised") {
37160
+ updateCreateOperation({
37161
+ workspaceSlug: args.workspaceSlug,
37162
+ kind: "document",
37163
+ logicalKey: args.slug,
37164
+ operationId: createProtocol.operationId,
37165
+ state: "finalized",
37166
+ rowId: doc2.id,
37167
+ now: args.now
37168
+ });
37169
+ }
36076
37170
  const createFm = args.parsed.data ?? {};
36077
37171
  const createLifted = splitUserFrontmatter(strategy, createFm);
36078
37172
  const userFm = userFrontmatter(strategy, createLifted.frontmatter);
@@ -36130,6 +37224,27 @@ async function runPushCreate(args) {
36130
37224
  base: args.state
36131
37225
  };
36132
37226
  }
37227
+ if (createResult.reason === "permanent_name_retired") {
37228
+ if (strategy.type !== "documents" || createResult.entityKind !== "document" || createResult.logicalKey !== args.slug) {
37229
+ throw new ApiError(409, "permanent-name refusal identity did not match the document create");
37230
+ }
37231
+ requireFeedRefresh({
37232
+ workspaceSlug: args.workspaceSlug,
37233
+ kind: "document",
37234
+ logicalKey: createResult.logicalKey,
37235
+ now: args.now
37236
+ });
37237
+ return {
37238
+ outcome: {
37239
+ kind: "permanent_delete_blocked",
37240
+ entityType: strategy.type,
37241
+ entitySlug: createResult.logicalKey,
37242
+ reason: "refresh_required"
37243
+ },
37244
+ state: args.state,
37245
+ base: args.state
37246
+ };
37247
+ }
36133
37248
  if (createResult.reason === "context_doc_conflict") {
36134
37249
  return reconcileContextDocConflict({
36135
37250
  echo: args.echo,
@@ -36218,7 +37333,7 @@ async function runPushDelete(args) {
36218
37333
  return args.mutex.run(id, async () => {
36219
37334
  const snapshot = getEntry(args.state, strategy.type, id) ?? null;
36220
37335
  const live = snapshot ? committedEntryIfNewer(args.workspaceSlug, strategy.type, snapshot) ?? snapshot : null;
36221
- if (existsSync4(args.absolutePath)) {
37336
+ if (existsSync5(args.absolutePath)) {
36222
37337
  args.output?.log(entityDeleteSuppressedLine({
36223
37338
  entityType: strategy.type,
36224
37339
  slug,
@@ -36310,7 +37425,7 @@ async function pushRename(args) {
36310
37425
  } = args;
36311
37426
  const fetchImpl = deps?.fetchImpl;
36312
37427
  const now = deps?.now ?? (() => new Date);
36313
- const mutex = deps?.mutex ?? new PerIdMutex;
37428
+ const mutex2 = deps?.mutex ?? new PerIdMutex;
36314
37429
  const output = deps?.output;
36315
37430
  const echo = deps?.echo;
36316
37431
  const strategy = requireStrategy(entityType);
@@ -36321,7 +37436,7 @@ async function pushRename(args) {
36321
37436
  } catch {
36322
37437
  toSlug = basename5(toPath, ".md");
36323
37438
  }
36324
- return mutex.run(id, async () => {
37439
+ return mutex2.run(id, async () => {
36325
37440
  const snapshot = getEntry(state, entityType, id);
36326
37441
  const live = snapshot ? committedEntryIfNewer(workspaceSlug, entityType, snapshot) ?? snapshot : undefined;
36327
37442
  const effectiveExpected = live?.updated_at ?? expectedUpdatedAt;
@@ -36371,7 +37486,7 @@ async function pushRename(args) {
36371
37486
  };
36372
37487
  if (!updated.body_hash || !updated.frontmatter_hash) {
36373
37488
  try {
36374
- const mirrorRaw = readFileSync18(toPath, "utf8");
37489
+ const mirrorRaw = readFileSync19(toPath, "utf8");
36375
37490
  const mirrorParsed = import_gray_matter10.default(mirrorRaw);
36376
37491
  const renameFm = mirrorParsed.data ?? {};
36377
37492
  const renameLifted = splitUserFrontmatter(strategy, renameFm);
@@ -36408,7 +37523,7 @@ async function pushRename(args) {
36408
37523
  if (patchResult.reason === "slug_conflict") {
36409
37524
  let contents2 = "";
36410
37525
  try {
36411
- contents2 = readFileSync18(toPath, "utf8");
37526
+ contents2 = readFileSync19(toPath, "utf8");
36412
37527
  } catch {}
36413
37528
  const stashPath2 = stashConflict({
36414
37529
  workspaceSlug,
@@ -36456,7 +37571,7 @@ async function pushRename(args) {
36456
37571
  }
36457
37572
  let contents = "";
36458
37573
  try {
36459
- contents = readFileSync18(toPath, "utf8");
37574
+ contents = readFileSync19(toPath, "utf8");
36460
37575
  } catch {}
36461
37576
  const stashPath = stashConflict({
36462
37577
  workspaceSlug,
@@ -36493,14 +37608,14 @@ async function pushRename(args) {
36493
37608
 
36494
37609
  // src/lib/resolve-pending.ts
36495
37610
  async function drainResolvedBatches(deps) {
36496
- const mutex = deps.mutex ?? new PerIdMutex;
37611
+ const mutex2 = deps.mutex ?? new PerIdMutex;
36497
37612
  let applied = 0;
36498
37613
  let discarded = 0;
36499
37614
  for (const batch of listPendingBatches(deps.workspaceSlug)) {
36500
37615
  if (batch.status === "pending")
36501
37616
  continue;
36502
37617
  if (batch.status === "approved") {
36503
- await applyBatch(batch, deps, mutex);
37618
+ await applyBatch(batch, deps, mutex2);
36504
37619
  deps.output?.log(`[DESTRUCTIVE_APPLIED batch=${batch.batch_id} ops=${batch.ops.length}]`);
36505
37620
  applied++;
36506
37621
  } else {
@@ -36515,7 +37630,7 @@ async function drainResolvedBatches(deps) {
36515
37630
  function opLabel(op) {
36516
37631
  return op.target === "file" ? `files/${op.path}` : `${op.entityType}/${op.slug}`;
36517
37632
  }
36518
- async function applyBatch(batch, deps, mutex) {
37633
+ async function applyBatch(batch, deps, mutex2) {
36519
37634
  for (const op of batch.ops) {
36520
37635
  try {
36521
37636
  if (op.target === "file") {
@@ -36535,7 +37650,7 @@ async function applyBatch(batch, deps, mutex) {
36535
37650
  deps: {
36536
37651
  fetchImpl: deps.fetchImpl,
36537
37652
  now: deps.now,
36538
- mutex,
37653
+ mutex: mutex2,
36539
37654
  output: deps.output,
36540
37655
  echo: deps.echo
36541
37656
  }
@@ -36589,7 +37704,7 @@ async function discardBatchOps(batch, deps) {
36589
37704
  const path5 = opPath(deps.workspaceSlug, op);
36590
37705
  let local = null;
36591
37706
  try {
36592
- local = readFileSync19(path5, "utf8");
37707
+ local = readFileSync20(path5, "utf8");
36593
37708
  } catch {
36594
37709
  local = null;
36595
37710
  }
@@ -36628,7 +37743,7 @@ async function discardFileOp(op, deps) {
36628
37743
  const abs = filesSubtreePath(deps.workspaceSlug, op.path);
36629
37744
  let local = null;
36630
37745
  try {
36631
- local = readFileSync19(abs);
37746
+ local = readFileSync20(abs);
36632
37747
  } catch {
36633
37748
  local = null;
36634
37749
  }
@@ -36971,7 +38086,7 @@ function unknownBecause(reason) {
36971
38086
  case "no_watermark":
36972
38087
  return "The last download carried no freshness marker, so there is nothing to compare against.";
36973
38088
  case "incomplete_apply":
36974
- return "The last download did not fully land, so this copy is not the one the freshness marker describes.";
38089
+ return "The last download either did not fully land or carried a correction trail this copy could not use, so this copy is not one the freshness marker can vouch for.";
36975
38090
  case "check_failed":
36976
38091
  return "The freshness check did not complete.";
36977
38092
  default:
@@ -37128,6 +38243,9 @@ function resolveTrail(row, slugById) {
37128
38243
  consistent: true
37129
38244
  };
37130
38245
  }
38246
+ if (outcome === "current" && successor === row.id) {
38247
+ return { trail: "current", consistent: true };
38248
+ }
37131
38249
  if (successor !== null)
37132
38250
  return { trail: UNKNOWN, consistent: false };
37133
38251
  return { trail: outcome, consistent: true };
@@ -37597,10 +38715,10 @@ class FileDestructiveGate {
37597
38715
  }
37598
38716
 
37599
38717
  // src/lib/files/reconcile.ts
37600
- import { readFileSync as readFileSync20, statSync as statSync10 } from "node:fs";
38718
+ import { readFileSync as readFileSync21, statSync as statSync10 } from "node:fs";
37601
38719
 
37602
38720
  // src/lib/unsynced.ts
37603
- import { join as join13, relative as relative8 } from "node:path";
38721
+ import { join as join14, relative as relative8 } from "node:path";
37604
38722
  var RECORDABLE_REASONS = new Set([
37605
38723
  "rate_limit",
37606
38724
  "server",
@@ -37645,7 +38763,7 @@ function recordFileUnsynced(args) {
37645
38763
  });
37646
38764
  }
37647
38765
  async function drainUnsyncedOps(args) {
37648
- const { workspaceSlug, baseUrl, pat, getState, applyState, fetchImpl, mutex, output, echo } = args;
38766
+ const { workspaceSlug, baseUrl, pat, getState, applyState, fetchImpl, mutex: mutex2, output, echo } = args;
37649
38767
  const now = args.now ?? (() => new Date);
37650
38768
  const ops = listUnsynced(getState());
37651
38769
  let attempted = 0;
@@ -37662,7 +38780,7 @@ async function drainUnsyncedOps(args) {
37662
38780
  attempted += 1;
37663
38781
  try {
37664
38782
  if (op.target === "entity") {
37665
- const absolutePath = join13(workspaceMirrorPath(workspaceSlug), op.path);
38783
+ const absolutePath = join14(workspaceMirrorPath(workspaceSlug), op.path);
37666
38784
  const classified = classifyMirrorPath({ workspaceSlug, absolutePath });
37667
38785
  if (!classified) {
37668
38786
  clear("entity", op.path);
@@ -37679,7 +38797,7 @@ async function drainUnsyncedOps(args) {
37679
38797
  state: base2,
37680
38798
  exists: op.op === "upsert",
37681
38799
  unlinkContext: null,
37682
- deps: { fetchImpl, now, mutex, output, echo }
38800
+ deps: { fetchImpl, now, mutex: mutex2, output, echo }
37683
38801
  });
37684
38802
  applyState(result.base ?? base2, result.state);
37685
38803
  if (result.outcome.kind === "ignored" && result.outcome.reason === DELETE_DECLINED_PATH_EXISTS) {
@@ -37707,7 +38825,7 @@ async function drainUnsyncedOps(args) {
37707
38825
  deps: { fetchImpl, now, output }
37708
38826
  });
37709
38827
  };
37710
- const result = mutex ? await mutex.run(`files:${op.path}`, doFile) : await doFile();
38828
+ const result = mutex2 ? await mutex2.run(`files:${op.path}`, doFile) : await doFile();
37711
38829
  applyState(base2, result.state);
37712
38830
  }
37713
38831
  clear(op.target, op.path);
@@ -38475,9 +39593,9 @@ class NodeFsHandler {
38475
39593
  if (this.fsw.closed) {
38476
39594
  return;
38477
39595
  }
38478
- const dirname11 = sp.dirname(file2);
39596
+ const dirname12 = sp.dirname(file2);
38479
39597
  const basename7 = sp.basename(file2);
38480
- const parent = this.fsw._getWatchedDir(dirname11);
39598
+ const parent = this.fsw._getWatchedDir(dirname12);
38481
39599
  let prevStats = stats;
38482
39600
  if (parent.has(basename7))
38483
39601
  return;
@@ -38504,7 +39622,7 @@ class NodeFsHandler {
38504
39622
  prevStats = newStats2;
38505
39623
  }
38506
39624
  } catch (error52) {
38507
- this.fsw._remove(dirname11, basename7);
39625
+ this.fsw._remove(dirname12, basename7);
38508
39626
  }
38509
39627
  } else if (parent.has(basename7)) {
38510
39628
  const at = newStats.atimeMs;
@@ -39592,7 +40710,12 @@ function startWatcher(opts, handler) {
39592
40710
  });
39593
40711
  watcher.on("add", (p) => classifier.onAdd(p));
39594
40712
  watcher.on("change", (p) => classifier.onChange(p));
39595
- watcher.on("unlink", (p) => classifier.onUnlink(p));
40713
+ watcher.on("unlink", (p) => {
40714
+ try {
40715
+ opts.onUnlink?.(p);
40716
+ } catch {}
40717
+ classifier.onUnlink(p);
40718
+ });
39596
40719
  return {
39597
40720
  async stop() {
39598
40721
  classifier.shutdown();
@@ -39631,7 +40754,7 @@ function readBlobForPush(args) {
39631
40754
  }
39632
40755
  let bytes;
39633
40756
  try {
39634
- bytes = readFileSync20(absolutePath);
40757
+ bytes = readFileSync21(absolutePath);
39635
40758
  } catch (err) {
39636
40759
  if (err?.code === "ENOENT")
39637
40760
  return { kind: "gone" };
@@ -39941,7 +41064,7 @@ function applyArtistMemoryOnPull(args) {
39941
41064
  const cloudMemory = live.memory;
39942
41065
  const generated = live.memory_generated === true;
39943
41066
  const local = readArtistMemoryFile(workspaceSlug, slug);
39944
- const refuse = (localContent, canonical) => {
41067
+ const refuse = (localContent, canonical2) => {
39945
41068
  const refused = refuseGeneratedMemoryEdit({
39946
41069
  state,
39947
41070
  workspaceSlug,
@@ -39951,7 +41074,7 @@ function applyArtistMemoryOnPull(args) {
39951
41074
  conflictEntity: ENTITY_TYPE,
39952
41075
  conflictSlug: conflictSlug(slug),
39953
41076
  localContent,
39954
- canonicalBody: canonical,
41077
+ canonicalBody: canonical2,
39955
41078
  writeCanonical: (body) => writeMemory(workspaceSlug, slug, body, suppressor),
39956
41079
  now,
39957
41080
  output: out
@@ -40055,7 +41178,7 @@ async function pushArtistMemory(args) {
40055
41178
  id: entry2.id,
40056
41179
  fetchImpl
40057
41180
  });
40058
- const canonical = row.memory ?? "";
41181
+ const canonical2 = row.memory ?? "";
40059
41182
  const refused = refuseGeneratedMemoryEdit({
40060
41183
  state: base2,
40061
41184
  workspaceSlug,
@@ -40065,7 +41188,7 @@ async function pushArtistMemory(args) {
40065
41188
  conflictEntity: ENTITY_TYPE,
40066
41189
  conflictSlug: conflictSlug(slug),
40067
41190
  localContent: local,
40068
- canonicalBody: canonical,
41191
+ canonicalBody: canonical2,
40069
41192
  writeCanonical: (body) => writeMemory(workspaceSlug, slug, body, suppressor),
40070
41193
  now,
40071
41194
  output
@@ -40074,7 +41197,7 @@ async function pushArtistMemory(args) {
40074
41197
  ...entry2,
40075
41198
  updated_at: row.updated_at,
40076
41199
  revision: row.revision,
40077
- memory_hash: stableHash(canonical),
41200
+ memory_hash: stableHash(canonical2),
40078
41201
  memory_generated: row.memory_generated
40079
41202
  };
40080
41203
  return {
@@ -40170,7 +41293,7 @@ function adoptCloudMemory(args) {
40170
41293
  return persistEntry2(state, updated);
40171
41294
  }
40172
41295
  async function reconcileArtistMemory(args) {
40173
- const { workspaceSlug, baseUrl, pat, fetchImpl, output, suppressor, mutex } = args;
41296
+ const { workspaceSlug, baseUrl, pat, fetchImpl, output, suppressor, mutex: mutex2 } = args;
40174
41297
  const now = args.now ?? (() => new Date);
40175
41298
  let state = args.state;
40176
41299
  let transitioned = 0;
@@ -40212,7 +41335,7 @@ async function reconcileArtistMemory(args) {
40212
41335
  now,
40213
41336
  output,
40214
41337
  suppressor,
40215
- mutex
41338
+ mutex: mutex2
40216
41339
  });
40217
41340
  state = result.state;
40218
41341
  if (result.outcome.kind === "pushed")
@@ -40692,10 +41815,10 @@ function anyUnknown(state) {
40692
41815
  }
40693
41816
 
40694
41817
  // src/lib/memory/write.ts
40695
- import { readFileSync as readFileSync21, statSync as statSync11 } from "node:fs";
41818
+ import { readFileSync as readFileSync22, statSync as statSync11 } from "node:fs";
40696
41819
 
40697
41820
  // src/lib/files/extracted-text.ts
40698
- import { createHash as createHash4 } from "node:crypto";
41821
+ import { createHash as createHash5 } from "node:crypto";
40699
41822
  var DERIVED_TEXT_VERSION = 2;
40700
41823
  function yamlString3(value) {
40701
41824
  return JSON.stringify(value);
@@ -40769,7 +41892,7 @@ ${doc2.text}
40769
41892
  `;
40770
41893
  }
40771
41894
  function extractedTextHash(rendered) {
40772
- return createHash4("sha256").update(rendered, "utf8").digest("hex");
41895
+ return createHash5("sha256").update(rendered, "utf8").digest("hex");
40773
41896
  }
40774
41897
 
40775
41898
  // src/lib/memory/splice.ts
@@ -40887,7 +42010,7 @@ function writeDocumentAnnotation(args) {
40887
42010
  }
40888
42011
  let current;
40889
42012
  try {
40890
- current = readFileSync21(target, "utf8");
42013
+ current = readFileSync22(target, "utf8");
40891
42014
  } catch {
40892
42015
  return { kind: "absent" };
40893
42016
  }
@@ -41716,6 +42839,109 @@ class PullLoop {
41716
42839
  }
41717
42840
  }
41718
42841
 
42842
+ // src/lib/high-water.ts
42843
+ var HIGH_WATER_BASE_READ_MS = 60 * 60 * 1000;
42844
+ function baseReadIntervalMs(env2 = process.env) {
42845
+ const raw = Number(env2.PLINTH_HIGH_WATER_BASE_READ_MS);
42846
+ return Number.isFinite(raw) && raw > 0 ? raw : HIGH_WATER_BASE_READ_MS;
42847
+ }
42848
+ function provenMark(args) {
42849
+ if (!args.clean)
42850
+ return null;
42851
+ return args.finalHighWater ?? args.advancedTo;
42852
+ }
42853
+ function createBaseReadSchedule(bootMs, intervalMs = HIGH_WATER_BASE_READ_MS) {
42854
+ let lastBaseReadMs = bootMs;
42855
+ let restoresWaiting = false;
42856
+ let lastUsedHighWater = false;
42857
+ return {
42858
+ useHighWater(nowMs) {
42859
+ if (restoresWaiting)
42860
+ return lastUsedHighWater;
42861
+ return nowMs - lastBaseReadMs < intervalMs;
42862
+ },
42863
+ completed(nowMs, usedHighWater, ok, restoresDeferred = 0) {
42864
+ if (!ok)
42865
+ return;
42866
+ if (!usedHighWater)
42867
+ lastBaseReadMs = nowMs;
42868
+ restoresWaiting = restoresDeferred > 0;
42869
+ lastUsedHighWater = usedHighWater;
42870
+ }
42871
+ };
42872
+ }
42873
+
42874
+ // src/lib/restore-deferral.ts
42875
+ var RESTORE_DEFERRAL_MS = 5 * 60000;
42876
+ var UNEXPLAINED_GRACE_MS = 5000;
42877
+ function keyOf(absolutePath) {
42878
+ return absolutePath.normalize("NFC");
42879
+ }
42880
+ function sightingKey(key, version2) {
42881
+ return `${key}\x00${version2}`;
42882
+ }
42883
+ function forgetSightings(recent, key) {
42884
+ const prefix = `${key}\x00`;
42885
+ for (const k of recent.keys())
42886
+ if (k.startsWith(prefix))
42887
+ recent.delete(k);
42888
+ }
42889
+ function noteUnlink(recent, absolutePath, nowMs) {
42890
+ forgetExpired(recent, nowMs);
42891
+ const key = keyOf(absolutePath);
42892
+ forgetSightings(recent, key);
42893
+ recent.set(key, { kind: "unlink", at: nowMs, logged: false });
42894
+ }
42895
+ function deferRestore(recent, absolutePath, version2, nowMs) {
42896
+ if (!recent)
42897
+ return { defer: false, first: false };
42898
+ forgetExpired(recent, nowMs);
42899
+ const key = keyOf(absolutePath);
42900
+ const unlinked = recent.get(key);
42901
+ if (unlinked?.kind === "unlink") {
42902
+ if (unlinked.logged)
42903
+ return { defer: true, first: false };
42904
+ recent.set(key, { ...unlinked, logged: true });
42905
+ return { defer: true, first: true };
42906
+ }
42907
+ const sighting = sightingKey(key, version2);
42908
+ const seen = recent.get(sighting);
42909
+ if (seen === undefined) {
42910
+ recent.set(sighting, { kind: "missing", at: nowMs });
42911
+ return { defer: true, first: false };
42912
+ }
42913
+ if (nowMs - seen.at < UNEXPLAINED_GRACE_MS)
42914
+ return { defer: true, first: false };
42915
+ recent.delete(sighting);
42916
+ return { defer: false, first: false };
42917
+ }
42918
+ function presentAgain(recent, absolutePath) {
42919
+ if (recent)
42920
+ forgetSightings(recent, keyOf(absolutePath));
42921
+ }
42922
+ function forgetExpired(recent, nowMs) {
42923
+ for (const [key, seen] of recent) {
42924
+ if (seen.kind === "unlink" && nowMs - seen.at >= RESTORE_DEFERRAL_MS)
42925
+ recent.delete(key);
42926
+ }
42927
+ }
42928
+
42929
+ // src/lib/ambient-poll.ts
42930
+ function createAmbientPollWiring(opts) {
42931
+ const recentUnlinks = new Map;
42932
+ const baseReads = createBaseReadSchedule(opts.now(), opts.baseReadIntervalMs);
42933
+ return {
42934
+ onUnlink: (absolutePath) => noteUnlink(recentUnlinks, absolutePath, opts.now()),
42935
+ async poll(run) {
42936
+ const startMs = opts.now();
42937
+ const useHighWater = baseReads.useHighWater(startMs);
42938
+ const result = await run({ useHighWater, recentUnlinks });
42939
+ baseReads.completed(startMs, useHighWater, result.exitCode === 0, result.restoresDeferred ?? 0);
42940
+ return result;
42941
+ }
42942
+ };
42943
+ }
42944
+
41719
42945
  // src/lib/calendar-loop.ts
41720
42946
  var DEFAULT_CALENDAR_INTERVAL_MS = 900000;
41721
42947
  var CALENDAR_BACKOFF_CAP_MS = 3600000;
@@ -41876,6 +43102,12 @@ var CALENDAR_STATUSES = [
41876
43102
  "token-expired",
41877
43103
  "google-error"
41878
43104
  ];
43105
+ var KNOWN_TRUNCATED_REASONS = ["page_limit", "time_limit"];
43106
+ var TruncatedReasonSchema = exports_external.string().nullish().transform((v) => {
43107
+ if (v === null || v === undefined)
43108
+ return null;
43109
+ return KNOWN_TRUNCATED_REASONS.includes(v) ? v : "other";
43110
+ });
41879
43111
  var CalendarRowSchema = exports_external.object({
41880
43112
  artist_id: exports_external.string().nullable(),
41881
43113
  artist_slug: exports_external.string().nullable(),
@@ -41884,8 +43116,13 @@ var CalendarRowSchema = exports_external.object({
41884
43116
  calendar_name: exports_external.string(),
41885
43117
  status: exports_external.enum(CALENDAR_STATUSES),
41886
43118
  events: exports_external.array(CalendarEventSchema).nullable(),
41887
- error: exports_external.object({ upstream_status: exports_external.number(), message: exports_external.string() }).nullish().transform((v) => v ?? null),
41888
- truncated: exports_external.boolean().optional().default(false)
43119
+ error: exports_external.object({
43120
+ upstream_status: exports_external.number().nullable(),
43121
+ message: exports_external.string()
43122
+ }).nullish().transform((v) => v ?? null),
43123
+ truncated: exports_external.boolean().optional().default(false),
43124
+ truncated_reason: TruncatedReasonSchema,
43125
+ resume_from: exports_external.string().nullish().transform((v) => v ?? null)
41889
43126
  });
41890
43127
  var CalendarResponseSchema = exports_external.object({
41891
43128
  generated_at: exports_external.string().min(1),
@@ -42073,6 +43310,62 @@ function readAsOf(text, today) {
42073
43310
  return { parts, yearInferred: first.yearInferred };
42074
43311
  }
42075
43312
 
43313
+ // src/lib/calendar/limits.ts
43314
+ var CALENDAR_RANGE_TOOL = "list_calendar_events";
43315
+ function spanPhrase(timeMin, timeMax) {
43316
+ const ms = Date.parse(timeMax) - Date.parse(timeMin);
43317
+ if (!Number.isFinite(ms) || ms <= 0)
43318
+ return null;
43319
+ const weeks = ms / (7 * DAY_MS);
43320
+ if (Number.isInteger(weeks))
43321
+ return `${weeks} ${weeks === 1 ? "week" : "weeks"}`;
43322
+ const days = ms / DAY_MS;
43323
+ if (Number.isInteger(days))
43324
+ return `${days} ${days === 1 ? "day" : "days"}`;
43325
+ return null;
43326
+ }
43327
+ function coverageLine(timeMin, timeMax) {
43328
+ const span = spanPhrase(timeMin, timeMax);
43329
+ return `**This file holds only the window above${span ? ` (${span})` : ""}.** ` + `When a question needs a date outside it, past or future, call the Plinth ` + `tool \`${CALENDAR_RANGE_TOOL}\` for just that range.`;
43330
+ }
43331
+ var TRUNCATION_CAUSE = {
43332
+ page_limit: "Google had more entries in this window than one refresh reads.",
43333
+ time_limit: "The read from Google ran out of time.",
43334
+ other: "The read stopped before the end of the window."
43335
+ };
43336
+ var TRUNCATION_SHORT = {
43337
+ page_limit: "stopped at the page limit",
43338
+ time_limit: "ran out of time",
43339
+ other: "stopped before the end of the window"
43340
+ };
43341
+ var OFFSET_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i;
43342
+ function resumeRange(resumeFrom, window2) {
43343
+ if (resumeFrom === null || !OFFSET_TIMESTAMP_RE.test(resumeFrom))
43344
+ return null;
43345
+ if (!OFFSET_TIMESTAMP_RE.test(window2.time_min) || !OFFSET_TIMESTAMP_RE.test(window2.time_max)) {
43346
+ return null;
43347
+ }
43348
+ const at = Date.parse(resumeFrom);
43349
+ const min = Date.parse(window2.time_min);
43350
+ const max = Date.parse(window2.time_max);
43351
+ if (!(at > min && at < max))
43352
+ return null;
43353
+ return { from: new Date(at).toISOString(), to: new Date(max).toISOString() };
43354
+ }
43355
+ function incompleteLine(args) {
43356
+ const { stamp: stamp2, eventCount, reason, resumeFrom, window: window2 } = args;
43357
+ const kept = eventCount === 0 ? "No entries were collected before the read stopped, so this is NOT a clear diary." : `${eventCount} ${eventCount === 1 ? "entry was" : "entries were"} collected before the read stopped.`;
43358
+ const range = resumeRange(resumeFrom, window2);
43359
+ const pointer = range ? ` For the rest, call the Plinth tool \`${CALENDAR_RANGE_TOOL}\` from ` + `\`${range.from}\` to \`${range.to}\` (a few entries repeat).` : ` For the rest, call the Plinth tool \`${CALENDAR_RANGE_TOOL}\` for a ` + `narrower range inside the window.`;
43360
+ return `**Read as of ${stamp2}, but this list is INCOMPLETE.** ` + `${TRUNCATION_CAUSE[reason ?? "other"]} ${kept} ` + `The rest of the window is not included: unknown, not absent.${pointer}`;
43361
+ }
43362
+ function sentence(text) {
43363
+ return /[.!?\u2026]$/.test(text) ? text : `${text}.`;
43364
+ }
43365
+ function truncationShort(reason) {
43366
+ return TRUNCATION_SHORT[reason ?? "other"];
43367
+ }
43368
+
42076
43369
  // src/lib/calendar/render.ts
42077
43370
  var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
42078
43371
  var OWNERSHIP_NOTE2 = [
@@ -42176,37 +43469,61 @@ function windowLine(timeMin, timeMax) {
42176
43469
  return `${timeMin} to ${timeMax}`;
42177
43470
  return `${formatDay(from, -1)} to ${formatDay(to, -1)}`;
42178
43471
  }
43472
+ function isRefusal(error52) {
43473
+ return error52.upstream_status !== null && error52.upstream_status >= 400;
43474
+ }
43475
+ function googleErrorLine(error52) {
43476
+ if (error52 !== null && !isRefusal(error52)) {
43477
+ return `**NOT READ - the read from Google did not complete.** ` + `${sentence(oneLine2(error52.message, 300))} The next refresh retries; until then ` + `nothing below is current, so treat this calendar as unknown rather than empty. ` + `For a date on it you need now, call the Plinth tool \`${CALENDAR_RANGE_TOOL}\` ` + `for a narrower range.`;
43478
+ }
43479
+ return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? sentence(oneLine2(error52.message, 300)) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
43480
+ }
42179
43481
  function statusLine(args) {
42180
43482
  const { status, eventCount, error: error52, truncated, stamp: stamp2, userLabel } = args;
42181
43483
  switch (status) {
42182
43484
  case "fetched": {
43485
+ if (truncated) {
43486
+ return incompleteLine({
43487
+ stamp: stamp2,
43488
+ eventCount,
43489
+ reason: args.truncatedReason,
43490
+ resumeFrom: args.resumeFrom,
43491
+ window: args.window
43492
+ });
43493
+ }
42183
43494
  if (eventCount === 0) {
42184
43495
  return `**Read fine as of ${stamp2}. Nothing in this window.** The diary really ` + `is clear across the dates above. This is a successful read of an empty ` + `calendar, not a failed read - if it had failed, this line would say so.`;
42185
43496
  }
42186
- const truncatedNote = truncated ? " **This list is INCOMPLETE**: Google had more entries than one page and the rest were not collected." : "";
42187
- return `**Read fine as of ${stamp2}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.${truncatedNote}`;
43497
+ return `**Read fine as of ${stamp2}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.`;
42188
43498
  }
42189
43499
  case "no-token":
42190
43500
  return `**NOT READ - Google is not connected for ${userLabel}.** Nothing is listed ` + `below because Plinth could not look, NOT because the diary is clear. ` + `Connect Google in Plinth to fill this in, and until then treat this ` + `calendar as unknown rather than empty.`;
42191
43501
  case "token-expired":
42192
43502
  return `**NOT READ - the Google connection for ${userLabel} has expired.** Plinth ` + `still holds a connection but could not refresh it, so nothing below is ` + `current. Reconnect Google in Plinth. Treat this calendar as unknown ` + `rather than empty.`;
42193
43503
  case "google-error":
42194
- return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine2(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
43504
+ return googleErrorLine(error52);
42195
43505
  }
42196
43506
  }
42197
43507
  function shortStatus(row) {
42198
43508
  switch (row.status) {
42199
43509
  case "fetched": {
42200
43510
  const n = row.events?.length ?? 0;
43511
+ const count = `${n} ${n === 1 ? "entry" : "entries"}`;
43512
+ if (row.truncated) {
43513
+ return `read, ${count}, INCOMPLETE (${truncationShort(row.truncated_reason)}); ` + `the rest of the window is unknown, not empty`;
43514
+ }
42201
43515
  if (n === 0)
42202
43516
  return "read fine, nothing in this window";
42203
- return `read fine, ${n} ${n === 1 ? "entry" : "entries"}${row.truncated ? " (INCOMPLETE - more than one page)" : ""}`;
43517
+ return `read fine, ${count}`;
42204
43518
  }
42205
43519
  case "no-token":
42206
43520
  return "NOT READ - Google is not connected; unknown, not empty";
42207
43521
  case "token-expired":
42208
43522
  return "NOT READ - the Google connection has expired; unknown, not empty";
42209
43523
  case "google-error":
43524
+ if (row.error !== null && !isRefusal(row.error)) {
43525
+ return `NOT READ - the read from Google did not complete: ` + `${oneLine2(row.error.message, 120)}; the next refresh retries; unknown, not empty`;
43526
+ }
42210
43527
  return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine2(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
42211
43528
  }
42212
43529
  }
@@ -42264,21 +43581,25 @@ function renderArtistCalendar(args) {
42264
43581
  eventCount: events.length,
42265
43582
  error: row.error,
42266
43583
  truncated: row.truncated,
43584
+ truncatedReason: row.truncated_reason,
43585
+ resumeFrom: row.resume_from,
43586
+ window: window2,
42267
43587
  stamp: stamp2,
42268
43588
  userLabel
42269
43589
  }),
42270
43590
  "",
42271
43591
  `**Window:** ${windowLine(window2.time_min, window2.time_max)}.`,
42272
43592
  "",
43593
+ ...row.status === "fetched" ? [coverageLine(window2.time_min, window2.time_max), ""] : [],
42273
43594
  "**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
42274
43595
  "",
42275
- renderBands(events, generatedAt, referenceYear, "")
43596
+ renderBands(events, generatedAt, referenceYear, "", row.truncated)
42276
43597
  ].join(`
42277
43598
  `);
42278
43599
  return `${header}${CALENDAR_BODY_MARKER}
42279
43600
  ${body}`;
42280
43601
  }
42281
- function renderBands(events, generatedAt, referenceYear, artistPrefix) {
43602
+ function renderBands(events, generatedAt, referenceYear, artistPrefix, truncated) {
42282
43603
  if (events.length === 0)
42283
43604
  return "";
42284
43605
  const { full, rest } = splitBands(events, generatedAt);
@@ -42292,7 +43613,7 @@ function renderBands(events, generatedAt, referenceYear, artistPrefix) {
42292
43613
  }
42293
43614
  out.push("", "## Rest of the window", "");
42294
43615
  if (rest.length === 0) {
42295
- out.push("_Nothing further in the window._");
43616
+ out.push(truncated ? "_Nothing further was collected: the read stopped before the end of the window._" : "_Nothing further in the window._");
42296
43617
  } else {
42297
43618
  for (const e of rest)
42298
43619
  out.push(compactLine(e, referenceYear, artistPrefix));
@@ -42301,6 +43622,9 @@ function renderBands(events, generatedAt, referenceYear, artistPrefix) {
42301
43622
  return out.join(`
42302
43623
  `);
42303
43624
  }
43625
+ function lacksConnection(row) {
43626
+ return row.status === "no-token" || row.status === "token-expired";
43627
+ }
42304
43628
  function renderUpcoming(args) {
42305
43629
  const {
42306
43630
  rows,
@@ -42316,6 +43640,7 @@ function renderUpcoming(args) {
42316
43640
  const fetched = rows.filter((r) => r.status === "fetched");
42317
43641
  const failed = rows.filter((r) => r.status !== "fetched");
42318
43642
  const truncatedAnywhere = rows.some((r) => r.truncated);
43643
+ const cutShort = fetched.filter((r) => r.truncated).length;
42319
43644
  const merged = fetched.flatMap((row) => (row.events ?? []).map((event) => ({ event, row }))).sort((a, b) => sortInstant(a.event) - sortInstant(b.event));
42320
43645
  const { full, rest } = splitBands(merged.map((m) => m.event), generatedAt);
42321
43646
  const fullSet = new Set(full);
@@ -42346,7 +43671,12 @@ function renderUpcoming(args) {
42346
43671
  "",
42347
43672
  `**As of ${stamp2}.** Window ${windowLine(window2.time_min, window2.time_max)}.`,
42348
43673
  "",
43674
+ ...rows.length > 0 && !rows.every(lacksConnection) ? [coverageLine(window2.time_min, window2.time_max), ""] : [],
42349
43675
  failed.length === 0 ? `**All ${fetched.length} mapped ${fetched.length === 1 ? "calendar was" : "calendars were"} read.**` : `**${fetched.length} of ${rows.length} mapped ${rows.length === 1 ? "calendar" : "calendars"} read; ${failed.length} could NOT be read.**` + ` Anything on an unread calendar is missing from the lists below - unknown, not absent.`,
43676
+ ...cutShort === 0 ? [] : [
43677
+ "",
43678
+ `**${cutShort} of the calendars read ${cutShort === 1 ? "was" : "were"} cut short** before the end ` + `of the window, so entries past the point where ${cutShort === 1 ? "it" : "each"} stopped are ` + `missing from the lists below - unknown, not absent. The status list says which.`
43679
+ ],
42350
43680
  "",
42351
43681
  `${artistCount} ${artistCount === 1 ? "artist" : "artists"} in the roster; ` + `${mappedArtistCount} ${mappedArtistCount === 1 ? "has" : "have"} a calendar mapped in Plinth.`,
42352
43682
  "",
@@ -42363,7 +43693,7 @@ function renderUpcoming(args) {
42363
43693
  body.push(`## Next ${FULL_BAND_DAYS} days`, "", "_Links are in the per-artist file named beside each calendar above._", "");
42364
43694
  const fullMerged = merged.filter((m) => fullSet.has(m.event));
42365
43695
  if (fullMerged.length === 0) {
42366
- body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
43696
+ body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : cutShort > 0 ? `_Nothing collected for the next ${FULL_BAND_DAYS} days on any calendar that was read; the ones cut short may be missing entries._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
42367
43697
  } else {
42368
43698
  for (const { event, row } of fullMerged) {
42369
43699
  body.push(fullLine(event, referenceYear, `**${rowDisplayName(row)}**`, false));
@@ -42372,7 +43702,7 @@ function renderUpcoming(args) {
42372
43702
  body.push("", "## Rest of the window", "");
42373
43703
  const restMerged = merged.filter((m) => restSet.has(m.event));
42374
43704
  if (restMerged.length === 0) {
42375
- body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : "_Nothing further in the window on any calendar that was read._");
43705
+ body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : cutShort > 0 ? "_Nothing further was collected on any calendar that was read; the ones cut short may be missing entries past where they stopped._" : "_Nothing further in the window on any calendar that was read._");
42376
43706
  } else {
42377
43707
  for (const { event, row } of restMerged) {
42378
43708
  body.push(compactLine(event, referenceYear, `**${rowDisplayName(row)}**`));
@@ -42619,7 +43949,7 @@ class PullSuppressor {
42619
43949
 
42620
43950
  // src/lib/discover-entities.ts
42621
43951
  var import_gray_matter12 = __toESM(require_gray_matter(), 1);
42622
- import { readFileSync as readFileSync22, statSync as statSync12 } from "node:fs";
43952
+ import { readFileSync as readFileSync23, statSync as statSync12 } from "node:fs";
42623
43953
  var DISCOVERY_WRITE_CAP_DEFAULT = 10;
42624
43954
  function discoveryWriteCap() {
42625
43955
  const raw = process.env.PLINTH_DISCOVERY_WRITE_CAP;
@@ -42718,7 +44048,7 @@ function planDiscoveryWrites(args) {
42718
44048
  counts.discovered++;
42719
44049
  let frontmatter;
42720
44050
  try {
42721
- frontmatter = import_gray_matter12.default(readFileSync22(absolutePath, "utf8")).data ?? {};
44051
+ frontmatter = import_gray_matter12.default(readFileSync23(absolutePath, "utf8")).data ?? {};
42722
44052
  } catch {
42723
44053
  counts.discovered--;
42724
44054
  continue;
@@ -42947,7 +44277,7 @@ function recordDiscoveryFailure(args) {
42947
44277
 
42948
44278
  // src/lib/frontmatter-id-backfill.ts
42949
44279
  var import_gray_matter13 = __toESM(require_gray_matter(), 1);
42950
- import { readFileSync as readFileSync23, statSync as statSync13 } from "node:fs";
44280
+ import { readFileSync as readFileSync24, statSync as statSync13 } from "node:fs";
42951
44281
  var FRONTMATTER_ID_BACKFILL_VERSION = 1;
42952
44282
  function backfillWorthReporting(r) {
42953
44283
  return r.written > 0 || r.failed > 0 || r.skipped.foreignId > 0 || r.skipped.unwritable > 0 || r.skipped.slugCollision > 0;
@@ -42990,7 +44320,7 @@ function safeInode3(path5) {
42990
44320
  function backfillFrontmatterIds(args) {
42991
44321
  const { workspaceSlug, state } = args;
42992
44322
  const compose = args.compose ?? withFrontmatterId;
42993
- const readRaw = args.readFile ?? ((p) => readFileSync23(p, "utf8"));
44323
+ const readRaw = args.readFile ?? ((p) => readFileSync24(p, "utf8"));
42994
44324
  const result = emptyResult();
42995
44325
  if (state.frontmatter_id_backfill_version === FRONTMATTER_ID_BACKFILL_VERSION) {
42996
44326
  return result;
@@ -43118,7 +44448,7 @@ function backfillFrontmatterIds(args) {
43118
44448
  }
43119
44449
 
43120
44450
  // src/lib/reconcile.ts
43121
- import { readFileSync as readFileSync24, statSync as statSync14 } from "node:fs";
44451
+ import { readFileSync as readFileSync25, statSync as statSync14 } from "node:fs";
43122
44452
  async function reconcileDivergedRows(args) {
43123
44453
  const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
43124
44454
  const now = args.now ?? (() => new Date);
@@ -43230,7 +44560,7 @@ async function reconcileDivergedRows(args) {
43230
44560
  }
43231
44561
  let content;
43232
44562
  try {
43233
- content = readFileSync24(absolutePath, "utf8");
44563
+ content = readFileSync25(absolutePath, "utf8");
43234
44564
  } catch (err) {
43235
44565
  if (err?.code === "ENOENT") {
43236
44566
  state = clearEntityCloudRowDeleted(state, strategy.mirrorRelativePath(entry2.slug));
@@ -43331,7 +44661,7 @@ async function reconcileDivergedRows(args) {
43331
44661
  }
43332
44662
 
43333
44663
  // src/lib/workspace-rules/sync.ts
43334
- import { existsSync as existsSync5 } from "node:fs";
44664
+ import { existsSync as existsSync6 } from "node:fs";
43335
44665
  import { relative as relative12 } from "node:path";
43336
44666
 
43337
44667
  // src/lib/workspace-rules/api.ts
@@ -43480,9 +44810,9 @@ function migrateHouseStyleFile(workspaceSlug) {
43480
44810
  return false;
43481
44811
  throw err;
43482
44812
  }
43483
- if (existsSync5(target))
44813
+ if (existsSync6(target))
43484
44814
  return false;
43485
- if (!existsSync5(legacy))
44815
+ if (!existsSync6(legacy))
43486
44816
  return false;
43487
44817
  return renameContextFile(workspaceSlug, HOUSE_STYLE_LEGACY_FILENAME, WORKSPACE_RULES_FILENAME);
43488
44818
  }
@@ -44580,17 +45910,18 @@ function createThrottledFetch(baseFetch, opts = {}) {
44580
45910
 
44581
45911
  // src/commands/sync.ts
44582
45912
  import {
44583
- existsSync as existsSync8,
45913
+ existsSync as existsSync9,
45914
+ lstatSync as lstatSync6,
44584
45915
  readdirSync as readdirSync7,
44585
- readFileSync as readFileSync27,
45916
+ readFileSync as readFileSync29,
44586
45917
  statSync as statSync17,
44587
- unlinkSync as unlinkSync8
45918
+ unlinkSync as unlinkSync9
44588
45919
  } from "node:fs";
44589
- import { join as join17 } from "node:path";
45920
+ import { join as join18 } from "node:path";
44590
45921
 
44591
45922
  // src/lib/conflicts-view.ts
44592
- import { existsSync as existsSync6 } from "node:fs";
44593
- import { join as join16 } from "node:path";
45923
+ import { existsSync as existsSync7 } from "node:fs";
45924
+ import { join as join17 } from "node:path";
44594
45925
  var NO_GENERATED_MEMORY = {
44595
45926
  artistMemoryGenerated: () => false,
44596
45927
  workspaceMemoryGenerated: () => false,
@@ -44636,7 +45967,7 @@ function resolveConflictRows(workspaceSlug, stashes, generatedMemory = NO_GENERA
44636
45967
  if (validateFilesRelPath(rel) === null) {
44637
45968
  return { kind: "unsafe-path", stash };
44638
45969
  }
44639
- const abs = join16(root, "files", rel);
45970
+ const abs = join17(root, "files", rel);
44640
45971
  if (hasSymlinkedComponent(workspaceSlug, abs)) {
44641
45972
  return { kind: "unsafe-path", stash };
44642
45973
  }
@@ -44644,7 +45975,7 @@ function resolveConflictRows(workspaceSlug, stashes, generatedMemory = NO_GENERA
44644
45975
  kind: "restorable",
44645
45976
  stash,
44646
45977
  mirrorPath: abs,
44647
- mirrorPathExists: existsSync6(abs)
45978
+ mirrorPathExists: existsSync7(abs)
44648
45979
  };
44649
45980
  });
44650
45981
  }
@@ -44766,9 +46097,385 @@ function refreshConflictsView(workspaceSlug) {
44766
46097
  }
44767
46098
 
44768
46099
  // src/lib/files/pull.ts
44769
- import { existsSync as existsSync7, lstatSync as lstatSync4, readFileSync as readFileSync25, statSync as statSync15 } from "node:fs";
44770
- import { dirname as dirname12 } from "node:path";
46100
+ import { existsSync as existsSync8, lstatSync as lstatSync5, readFileSync as readFileSync27, statSync as statSync15 } from "node:fs";
46101
+ import { dirname as dirname13 } from "node:path";
46102
+
46103
+ // src/lib/permanent-disposition.ts
46104
+ import { createHash as createHash6 } from "node:crypto";
46105
+ import { readFileSync as readFileSync26 } from "node:fs";
46106
+ function sha256(value) {
46107
+ return createHash6("sha256").update(value).digest("hex");
46108
+ }
46109
+ function verifyStash(path5, expectedHash) {
46110
+ const actual = sha256(readFileSync26(path5));
46111
+ if (actual !== expectedHash) {
46112
+ throw new Error(`permanent deletion stash verification failed for ${path5}`);
46113
+ }
46114
+ }
46115
+ function disposePermanentDocument(args) {
46116
+ return withPermanentDeletionLock("document", args.tombstone.logicalKey, async () => disposePermanentDocumentLocked(args));
46117
+ }
46118
+ function disposePermanentDocumentLocked(args) {
46119
+ const now = args.now ?? (() => new Date);
46120
+ const t = args.tombstone;
46121
+ recordRetirementPending({
46122
+ workspaceSlug: args.workspaceSlug,
46123
+ kind: "document",
46124
+ logicalKey: t.logicalKey,
46125
+ rowId: t.id,
46126
+ purgeGeneration: t.purgeGeneration,
46127
+ retirementSequence: t.retirementSequence,
46128
+ retiredAt: t.deletedAt
46129
+ });
46130
+ let journal = loadPermanentDeletionJournal(args.workspaceSlug);
46131
+ let record2 = findRetirement(journal, "document", t.logicalKey);
46132
+ if (record2.purge_generation !== t.purgeGeneration) {
46133
+ return { state: args.state, deleted: false };
46134
+ }
46135
+ if (args.liveOwnerId === t.id) {
46136
+ throw new Error("permanent document namespace reports the retired row as live owner");
46137
+ }
46138
+ if (record2.disposition === "complete") {
46139
+ const stale = args.liveOwnerId === null ? getEntryBySlug(args.state, args.strategy.type, t.logicalKey) : getEntry(args.state, args.strategy.type, t.id);
46140
+ const state2 = stale ? removeEntry(args.state, args.strategy.type, stale.id).state : args.state;
46141
+ return { state: state2, deleted: false, stashPath: record2.stash_path };
46142
+ }
46143
+ const retiredEntry = getEntry(args.state, args.strategy.type, t.id);
46144
+ const stateOwner = getEntryBySlug(args.state, args.strategy.type, t.logicalKey);
46145
+ const protectedOwner = args.liveOwnerId !== null && args.liveOwnerId !== t.id;
46146
+ let state = args.state;
46147
+ let deleted = false;
46148
+ let stashPath = record2.stash_path;
46149
+ let stashHash = record2.stash_hash;
46150
+ if (!protectedOwner) {
46151
+ const target = entityFilePath(args.workspaceSlug, args.strategy.type, retiredEntry?.slug ?? t.logicalKey);
46152
+ let raw;
46153
+ try {
46154
+ raw = readFileSync26(target, "utf8");
46155
+ } catch (error52) {
46156
+ if (error52?.code !== "ENOENT")
46157
+ throw error52;
46158
+ }
46159
+ if (raw !== undefined && !stashPath) {
46160
+ let matchesAcknowledged = false;
46161
+ if (retiredEntry) {
46162
+ try {
46163
+ const hashes = computeMirrorHashes(args.strategy, raw);
46164
+ matchesAcknowledged = hashes.bodyHash === retiredEntry.body_hash && hashes.frontmatterHash === retiredEntry.frontmatter_hash;
46165
+ } catch {
46166
+ matchesAcknowledged = false;
46167
+ }
46168
+ }
46169
+ if (!matchesAcknowledged) {
46170
+ const stash = stashEntityConflictOnce({
46171
+ workspaceSlug: args.workspaceSlug,
46172
+ entityType: args.strategy.type,
46173
+ entitySlug: t.logicalKey,
46174
+ timestamp: now(),
46175
+ contents: raw,
46176
+ side: "local"
46177
+ });
46178
+ stashPath = stash.path;
46179
+ stashHash = sha256(raw);
46180
+ verifyStash(stash.path, stashHash);
46181
+ recordRetirementStash({
46182
+ workspaceSlug: args.workspaceSlug,
46183
+ kind: "document",
46184
+ logicalKey: t.logicalKey,
46185
+ generation: t.purgeGeneration,
46186
+ stashPath,
46187
+ stashHash
46188
+ });
46189
+ args.output?.warn(`${args.strategy.type}/${t.logicalKey}: permanently deleted; recoverable local content was moved to ${stashPath}.`);
46190
+ }
46191
+ }
46192
+ args.suppressor?.registerDelete(target);
46193
+ deleted = deleteMirrorFile(args.workspaceSlug, args.strategy.type, retiredEntry?.slug ?? t.logicalKey);
46194
+ }
46195
+ const staleEntry = args.liveOwnerId === null ? stateOwner : retiredEntry;
46196
+ if (staleEntry)
46197
+ state = removeEntry(state, args.strategy.type, staleEntry.id).state;
46198
+ completeRetirement({
46199
+ workspaceSlug: args.workspaceSlug,
46200
+ kind: "document",
46201
+ logicalKey: t.logicalKey,
46202
+ generation: t.purgeGeneration,
46203
+ ...stashPath && stashHash ? { stashPath, stashHash } : {},
46204
+ now
46205
+ });
46206
+ return { state, deleted, ...stashPath ? { stashPath } : {} };
46207
+ }
46208
+ function disposePermanentFile(args) {
46209
+ return withPermanentDeletionLock("workspace_file", args.tombstone.logicalKey, async () => disposePermanentFileLocked(args));
46210
+ }
46211
+ function disposePermanentFileLocked(args) {
46212
+ const now = args.now ?? (() => new Date);
46213
+ const t = args.tombstone;
46214
+ recordRetirementPending({
46215
+ workspaceSlug: args.workspaceSlug,
46216
+ kind: "workspace_file",
46217
+ logicalKey: t.logicalKey,
46218
+ rowId: t.id,
46219
+ purgeGeneration: t.purgeGeneration,
46220
+ retirementSequence: t.retirementSequence,
46221
+ retiredAt: t.deletedAt
46222
+ });
46223
+ const journal = loadPermanentDeletionJournal(args.workspaceSlug);
46224
+ const record2 = findRetirement(journal, "workspace_file", t.logicalKey);
46225
+ if (record2.purge_generation !== t.purgeGeneration) {
46226
+ return { state: args.state, deleted: false, textDeleted: false };
46227
+ }
46228
+ if (args.liveOwnerId === t.id) {
46229
+ throw new Error("permanent file namespace reports the retired row as live owner");
46230
+ }
46231
+ if (record2.disposition === "complete") {
46232
+ const stale = getFileEntry(args.state, t.logicalKey);
46233
+ const state2 = stale && (args.liveOwnerId === null || stale.id === t.id) ? removeFileEntry(args.state, t.logicalKey).state : args.state;
46234
+ return {
46235
+ state: state2,
46236
+ deleted: false,
46237
+ textDeleted: false,
46238
+ ...record2.stash_path ? { stashPath: record2.stash_path } : {}
46239
+ };
46240
+ }
46241
+ const existing = getFileEntry(args.state, t.logicalKey);
46242
+ const protectedOwner = args.liveOwnerId !== null && args.liveOwnerId !== t.id;
46243
+ let state = args.state;
46244
+ let deleted = false;
46245
+ let textDeleted = false;
46246
+ let stashPath = record2.stash_path;
46247
+ let stashHash = record2.stash_hash;
46248
+ if (!protectedOwner) {
46249
+ const target = filesSubtreePath(args.workspaceSlug, t.logicalKey);
46250
+ let bytes;
46251
+ try {
46252
+ bytes = readFileSync26(target);
46253
+ } catch (error52) {
46254
+ if (error52?.code !== "ENOENT")
46255
+ throw error52;
46256
+ }
46257
+ if (bytes !== undefined && !stashPath) {
46258
+ const matchesAcknowledged = existing !== undefined && sha256Bytes(bytes) === existing.content_hash;
46259
+ if (!matchesAcknowledged) {
46260
+ const stash = stashFileConflictOnce({
46261
+ workspaceSlug: args.workspaceSlug,
46262
+ relPath: t.logicalKey,
46263
+ timestamp: now(),
46264
+ bytes,
46265
+ side: "local"
46266
+ });
46267
+ stashPath = stash.path;
46268
+ stashHash = sha256(bytes);
46269
+ verifyStash(stash.path, stashHash);
46270
+ recordRetirementStash({
46271
+ workspaceSlug: args.workspaceSlug,
46272
+ kind: "workspace_file",
46273
+ logicalKey: t.logicalKey,
46274
+ generation: t.purgeGeneration,
46275
+ stashPath,
46276
+ stashHash
46277
+ });
46278
+ args.output?.warn(`files/${t.logicalKey}: permanently deleted; recoverable local bytes were moved to ${stashPath}.`);
46279
+ }
46280
+ }
46281
+ args.suppressor?.registerDelete(target);
46282
+ deleted = deleteFilesFile(args.workspaceSlug, t.logicalKey);
46283
+ textDeleted = deleteExtractedText(args.workspaceSlug, t.logicalKey);
46284
+ }
46285
+ if (existing && (args.liveOwnerId === null || existing.id === t.id)) {
46286
+ state = removeFileEntry(state, t.logicalKey).state;
46287
+ }
46288
+ completeRetirement({
46289
+ workspaceSlug: args.workspaceSlug,
46290
+ kind: "workspace_file",
46291
+ logicalKey: t.logicalKey,
46292
+ generation: t.purgeGeneration,
46293
+ ...stashPath && stashHash ? { stashPath, stashHash } : {},
46294
+ now
46295
+ });
46296
+ return { state, deleted, textDeleted, ...stashPath ? { stashPath } : {} };
46297
+ }
46298
+
46299
+ // src/lib/files/pull.ts
46300
+ function dueFileRefreshMarkers(workspaceSlug, now) {
46301
+ try {
46302
+ return loadPermanentDeletionJournal(workspaceSlug).refresh.filter((marker) => marker.kind === "workspace_file" && (marker.retry_after === undefined || new Date(marker.retry_after).getTime() <= now.getTime()));
46303
+ } catch {
46304
+ return null;
46305
+ }
46306
+ }
46307
+ async function replayPermanentFiles(args, markers) {
46308
+ const now = args.now ?? (() => new Date);
46309
+ const rows = [];
46310
+ let cursor = null;
46311
+ const savedCursor = args.baseCursor === undefined ? args.cursor : args.baseCursor;
46312
+ let resultingCursor = savedCursor;
46313
+ while (true) {
46314
+ const page = await syncFilesPage({
46315
+ baseUrl: args.baseUrl,
46316
+ pat: args.pat,
46317
+ cursor,
46318
+ fetchImpl: args.fetchImpl
46319
+ });
46320
+ rows.push(...page.rows);
46321
+ if (page.next_cursor === null)
46322
+ break;
46323
+ resultingCursor = page.next_cursor;
46324
+ cursor = page.next_cursor;
46325
+ }
46326
+ let state = args.state;
46327
+ let written = 0;
46328
+ let deleted = 0;
46329
+ let textWritten = 0;
46330
+ let textDeleted = 0;
46331
+ let textUnknown = 0;
46332
+ let pointerRows = 0;
46333
+ let symlinkRefused = 0;
46334
+ let stashFailed = 0;
46335
+ let restoresDeferred = 0;
46336
+ let completePass = true;
46337
+ let lastCommittedState = args.state;
46338
+ const completed = [];
46339
+ for (const marker of markers) {
46340
+ const candidates = rows.filter((row) => row.path === marker.logical_key && row.deleted_at !== null && row.permanent_delete === true && typeof row.purge_generation === "string" && typeof row.retirement_sequence === "number");
46341
+ candidates.sort((a, b) => b.retirement_sequence - a.retirement_sequence);
46342
+ const candidate = candidates[0];
46343
+ const ambiguousLatest = candidate !== undefined && candidates.some((row) => row.retirement_sequence === candidate.retirement_sequence && row.purge_generation !== candidate.purge_generation);
46344
+ const stateBefore = state;
46345
+ const outcome = await withPermanentDeletionLock("workspace_file", marker.logical_key, async () => {
46346
+ const namespace = await getCurrentPermanentNamespace({
46347
+ baseUrl: args.baseUrl,
46348
+ pat: args.pat,
46349
+ entityKind: "workspace_file",
46350
+ logicalKey: marker.logical_key,
46351
+ fetchImpl: args.fetchImpl
46352
+ });
46353
+ if (!candidate || ambiguousLatest || namespace.latest_retirement === null || namespace.latest_retirement.purge_generation !== candidate.purge_generation || namespace.latest_retirement.retirement_sequence !== candidate.retirement_sequence) {
46354
+ markRetentionMissing({
46355
+ workspaceSlug: args.workspaceSlug,
46356
+ kind: "workspace_file",
46357
+ logicalKey: marker.logical_key,
46358
+ now
46359
+ });
46360
+ args.output?.warn(`files/${marker.logical_key}: permanent-delete replay could not prove one unambiguous current retained tombstone; creates remain fenced and the replay will retry.`);
46361
+ return { state: stateBefore, deleted: false, textDeleted: false, complete: false };
46362
+ }
46363
+ const liveOwnerId = namespace.live_owner?.id ?? null;
46364
+ const disposition = await disposePermanentFile({
46365
+ workspaceSlug: args.workspaceSlug,
46366
+ tombstone: {
46367
+ id: candidate.id,
46368
+ logicalKey: marker.logical_key,
46369
+ deletedAt: candidate.deleted_at,
46370
+ purgeGeneration: candidate.purge_generation,
46371
+ retirementSequence: candidate.retirement_sequence
46372
+ },
46373
+ liveOwnerId,
46374
+ state: stateBefore,
46375
+ now,
46376
+ output: args.output,
46377
+ suppressor: args.suppressor
46378
+ });
46379
+ const committedState = args.commit && disposition.state !== stateBefore ? args.commit(stateBefore, disposition.state) : disposition.state;
46380
+ const current = await getCurrentPermanentNamespace({
46381
+ baseUrl: args.baseUrl,
46382
+ pat: args.pat,
46383
+ entityKind: "workspace_file",
46384
+ logicalKey: marker.logical_key,
46385
+ fetchImpl: args.fetchImpl
46386
+ });
46387
+ const complete = current.latest_retirement?.purge_generation === candidate.purge_generation && current.latest_retirement?.retirement_sequence === candidate.retirement_sequence && (current.live_owner?.id ?? null) === liveOwnerId;
46388
+ return {
46389
+ state: committedState,
46390
+ deleted: disposition.deleted,
46391
+ textDeleted: disposition.textDeleted,
46392
+ complete
46393
+ };
46394
+ });
46395
+ state = outcome.state;
46396
+ if (args.commit && state !== stateBefore)
46397
+ lastCommittedState = state;
46398
+ if (outcome.deleted)
46399
+ deleted++;
46400
+ if (outcome.textDeleted)
46401
+ textDeleted++;
46402
+ if (outcome.complete && candidate) {
46403
+ completed.push({ logicalKey: marker.logical_key, generation: candidate.purge_generation });
46404
+ } else {
46405
+ completePass = false;
46406
+ }
46407
+ }
46408
+ for (const row of rows) {
46409
+ if (row.deleted_at !== null && row.permanent_delete === true)
46410
+ continue;
46411
+ const outcome = await applyFileRow({
46412
+ workspaceSlug: args.workspaceSlug,
46413
+ baseUrl: args.baseUrl,
46414
+ pat: args.pat,
46415
+ row,
46416
+ state,
46417
+ fetchImpl: args.fetchImpl,
46418
+ now,
46419
+ output: args.output,
46420
+ suppressor: args.suppressor,
46421
+ isFileFrozen: args.isFileFrozen,
46422
+ loggedTombstoneSkips: args.loggedTombstoneSkips,
46423
+ forceTextRewalk: args.forceTextRewalk,
46424
+ loggedStashNotices: args.loggedStashNotices,
46425
+ recentUnlinks: args.recentUnlinks
46426
+ });
46427
+ state = outcome.state;
46428
+ if (outcome.action === "written")
46429
+ written++;
46430
+ else if (outcome.action === "deleted")
46431
+ deleted++;
46432
+ textWritten += outcome.textWritten ?? 0;
46433
+ textDeleted += outcome.textDeleted ?? 0;
46434
+ if (outcome.textUnknown)
46435
+ textUnknown++;
46436
+ if (outcome.pointer)
46437
+ pointerRows++;
46438
+ if (outcome.symlinkRefused) {
46439
+ symlinkRefused++;
46440
+ completePass = false;
46441
+ }
46442
+ if (outcome.stashFailed || outcome.textFailed || outcome.integrityFailed) {
46443
+ if (outcome.stashFailed)
46444
+ stashFailed++;
46445
+ completePass = false;
46446
+ }
46447
+ if (outcome.restoreDeferred) {
46448
+ restoresDeferred++;
46449
+ completePass = false;
46450
+ }
46451
+ }
46452
+ if (args.commit && state !== lastCommittedState)
46453
+ state = args.commit(lastCommittedState, state);
46454
+ return {
46455
+ state,
46456
+ fetched: rows.length,
46457
+ written,
46458
+ deleted,
46459
+ textWritten,
46460
+ textDeleted,
46461
+ textUnknown,
46462
+ pointerRows,
46463
+ symlinkRefused,
46464
+ stashFailed,
46465
+ lastCursor: completePass ? resultingCursor : savedCursor,
46466
+ restoresDeferred,
46467
+ permanentRefreshes: completed,
46468
+ permanentReplayComplete: completePass
46469
+ };
46470
+ }
44771
46471
  async function pullFiles(args) {
46472
+ const dueMarkers = dueFileRefreshMarkers(args.workspaceSlug, (args.now ?? (() => new Date))());
46473
+ if (dueMarkers && dueMarkers.length > 0)
46474
+ return replayPermanentFiles(args, dueMarkers);
46475
+ const permanentJournalBlocked = dueMarkers === null;
46476
+ if (permanentJournalBlocked) {
46477
+ args.output?.warn(`files: permanent deletion journal is invalid or unreadable; ordinary rows may apply, but the files cursor and permanent dispositions are held. Run \`plinth status\` for the journal path.`);
46478
+ }
44772
46479
  const { workspaceSlug, baseUrl, pat, fetchImpl } = args;
44773
46480
  let state = args.state;
44774
46481
  let fetched = 0;
@@ -44780,15 +46487,47 @@ async function pullFiles(args) {
44780
46487
  let pointerRows = 0;
44781
46488
  let symlinkRefused = 0;
44782
46489
  let stashFailed = 0;
46490
+ let restoresDeferred = 0;
46491
+ let permanentEncountered = false;
46492
+ const baseCursor = args.baseCursor === undefined ? args.cursor : args.baseCursor;
44783
46493
  let cursor = args.cursor;
44784
46494
  let lastCursor = args.cursor;
44785
- let cursorBlocked = false;
46495
+ let finalHighWater = null;
46496
+ let markRefused = false;
46497
+ let cursorBlocked = permanentJournalBlocked;
44786
46498
  while (true) {
44787
- const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
46499
+ let page;
46500
+ try {
46501
+ page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
46502
+ } catch (err) {
46503
+ if (!markRefused && cursor === args.cursor && args.cursor !== baseCursor && err instanceof ApiError && err.status === 400) {
46504
+ markRefused = true;
46505
+ cursor = baseCursor;
46506
+ lastCursor = baseCursor;
46507
+ continue;
46508
+ }
46509
+ throw err;
46510
+ }
44788
46511
  const pageBase = state;
44789
46512
  let pageTextFailed = false;
44790
46513
  for (const row of page.rows) {
44791
46514
  fetched++;
46515
+ if (row.deleted_at !== null && row.permanent_delete === true && row.purge_generation !== undefined) {
46516
+ if (permanentJournalBlocked) {
46517
+ pageTextFailed = true;
46518
+ continue;
46519
+ }
46520
+ requireFeedRefresh({
46521
+ workspaceSlug,
46522
+ kind: "workspace_file",
46523
+ logicalKey: row.path,
46524
+ requiredGeneration: row.purge_generation,
46525
+ now: args.now
46526
+ });
46527
+ permanentEncountered = true;
46528
+ pageTextFailed = true;
46529
+ continue;
46530
+ }
44792
46531
  const outcome = await applyFileRow({
44793
46532
  workspaceSlug,
44794
46533
  baseUrl,
@@ -44802,7 +46541,8 @@ async function pullFiles(args) {
44802
46541
  isFileFrozen: args.isFileFrozen,
44803
46542
  loggedTombstoneSkips: args.loggedTombstoneSkips,
44804
46543
  forceTextRewalk: args.forceTextRewalk,
44805
- loggedStashNotices: args.loggedStashNotices
46544
+ loggedStashNotices: args.loggedStashNotices,
46545
+ recentUnlinks: args.recentUnlinks
44806
46546
  });
44807
46547
  state = outcome.state;
44808
46548
  if (!outcome.symlinkRefused) {
@@ -44828,17 +46568,34 @@ async function pullFiles(args) {
44828
46568
  stashFailed++;
44829
46569
  pageTextFailed = true;
44830
46570
  }
46571
+ if (outcome.integrityFailed)
46572
+ pageTextFailed = true;
46573
+ if (outcome.restoreDeferred) {
46574
+ restoresDeferred++;
46575
+ pageTextFailed = true;
46576
+ }
44831
46577
  }
44832
46578
  if (args.commit && state !== pageBase)
44833
46579
  state = args.commit(pageBase, state);
44834
46580
  if (pageTextFailed)
44835
46581
  cursorBlocked = true;
44836
- if (page.next_cursor === null)
46582
+ if (page.next_cursor === null) {
46583
+ finalHighWater = page.high_water ?? null;
44837
46584
  break;
46585
+ }
44838
46586
  if (!cursorBlocked)
44839
46587
  lastCursor = page.next_cursor;
44840
46588
  cursor = page.next_cursor;
44841
46589
  }
46590
+ if (permanentEncountered) {
46591
+ const markers = dueFileRefreshMarkers(workspaceSlug, (args.now ?? (() => new Date))());
46592
+ if (!markers) {
46593
+ throw new Error("permanent deletion journal became unreadable during file replay");
46594
+ }
46595
+ if (markers.length > 0) {
46596
+ return replayPermanentFiles({ ...args, state }, markers);
46597
+ }
46598
+ }
44842
46599
  if (textUnknown > 0) {
44843
46600
  args.output?.warn(`${textUnknown} file ${textUnknown === 1 ? "row" : "rows"} arrived without a readable \`has_extracted_text\` — this server predates the extracted-text contract, or the field was dropped from the payload. Derived text under views/extracted/ was left untouched rather than deleted, and is not being updated either.`);
44844
46601
  }
@@ -44862,7 +46619,14 @@ async function pullFiles(args) {
44862
46619
  pointerRows,
44863
46620
  symlinkRefused,
44864
46621
  stashFailed,
44865
- lastCursor
46622
+ lastCursor,
46623
+ highWater: provenMark({
46624
+ clean: !cursorBlocked && textUnknown === 0,
46625
+ finalHighWater,
46626
+ advancedTo: lastCursor !== (markRefused ? baseCursor : args.cursor) ? lastCursor : null
46627
+ }),
46628
+ markRefused,
46629
+ restoresDeferred
44866
46630
  };
44867
46631
  }
44868
46632
  async function applyExtractedText(args) {
@@ -44977,7 +46741,7 @@ async function applyFileRow(args) {
44977
46741
  }
44978
46742
  let localBytes;
44979
46743
  try {
44980
- localBytes = readFileSync25(abs);
46744
+ localBytes = readFileSync27(abs);
44981
46745
  } catch {
44982
46746
  localBytes = undefined;
44983
46747
  }
@@ -45023,7 +46787,18 @@ async function applyFileRow(args) {
45023
46787
  output
45024
46788
  });
45025
46789
  }
45026
- if (existing && existing.content_hash === row.content_hash && existsSync7(abs)) {
46790
+ if (existing && existing.content_hash === row.content_hash && !existsSync8(abs)) {
46791
+ const verdict = deferRestore(args.recentUnlinks, abs, row.updated_at, now().getTime());
46792
+ if (verdict.defer) {
46793
+ if (verdict.first) {
46794
+ output?.log(fileRestoreDeferredLine({ path: relPath, waitSeconds: RESTORE_DEFERRAL_MS / 1000 }));
46795
+ }
46796
+ return { state, action: "noop", textUnknown, restoreDeferred: true };
46797
+ }
46798
+ } else {
46799
+ presentAgain(args.recentUnlinks, abs);
46800
+ }
46801
+ if (existing && existing.content_hash === row.content_hash && existsSync8(abs)) {
45027
46802
  if (!args.forceTextRewalk && textIsCurrent(existing, row, workspaceSlug, relPath)) {
45028
46803
  return { state, action: "noop", textUnknown };
45029
46804
  }
@@ -45075,7 +46850,7 @@ async function applyFileRow(args) {
45075
46850
  const bytes = await downloadBytes(got.row.download_url, fetchImpl);
45076
46851
  if (sha256Bytes(bytes) !== row.content_hash) {
45077
46852
  output?.warn(`Integrity check failed for files/${relPath} (hash mismatch) — skipped this pull.`);
45078
- return { state, action: "skipped" };
46853
+ return { state, action: "skipped", integrityFailed: true };
45079
46854
  }
45080
46855
  const stashFailure = stashUnpushedLocalEdit({
45081
46856
  workspaceSlug,
@@ -45153,7 +46928,7 @@ async function applyPointerRow(args) {
45153
46928
  if (textSignal === "absent") {
45154
46929
  args.output?.warn(`files/${relPath}: Plinth holds neither bytes nor text for this file — it has no local representation in the mirror.`);
45155
46930
  }
45156
- const bytesInMirror = existsSync7(filesSubtreePath(workspaceSlug, relPath));
46931
+ const bytesInMirror = existsSync8(filesSubtreePath(workspaceSlug, relPath));
45157
46932
  if (bytesInMirror) {
45158
46933
  args.output?.warn(`files/${relPath}: Plinth holds no bytes for this file, but a local copy is present at that path — left exactly as it is. Plinth will not update it.`);
45159
46934
  }
@@ -45179,11 +46954,11 @@ async function applyPointerRow(args) {
45179
46954
  }
45180
46955
  function stashUnpushedLocalEdit(args) {
45181
46956
  const { workspaceSlug, relPath, abs, existing, output } = args;
45182
- if (hasSymlinkedComponent(workspaceSlug, dirname12(abs)))
46957
+ if (hasSymlinkedComponent(workspaceSlug, dirname13(abs)))
45183
46958
  return { reason: "symlink" };
45184
46959
  let st;
45185
46960
  try {
45186
- st = lstatSync4(abs);
46961
+ st = lstatSync5(abs);
45187
46962
  } catch (err) {
45188
46963
  if (err?.code === "ENOENT")
45189
46964
  return null;
@@ -45193,7 +46968,7 @@ function stashUnpushedLocalEdit(args) {
45193
46968
  return { reason: "not_a_file" };
45194
46969
  let localBytes;
45195
46970
  try {
45196
- localBytes = readFileSync25(abs);
46971
+ localBytes = readFileSync27(abs);
45197
46972
  } catch (err) {
45198
46973
  return { reason: "unreadable", code: errCode(err) };
45199
46974
  }
@@ -45230,10 +47005,12 @@ function textIsCurrent(existing, row, workspaceSlug, relPath) {
45230
47005
  if (signal === "unknown")
45231
47006
  return true;
45232
47007
  if (signal === "absent") {
45233
- return existing.text_hash === undefined && !existsSync7(extractedTextPath(workspaceSlug, relPath));
47008
+ return existing.text_hash === undefined && !existsSync8(extractedTextPath(workspaceSlug, relPath));
45234
47009
  }
45235
47010
  if (existing.text_hash === undefined)
45236
47011
  return false;
47012
+ if (existing.updated_at !== row.updated_at)
47013
+ return false;
45237
47014
  const onDisk = readExtractedText(workspaceSlug, relPath);
45238
47015
  if (onDisk === null)
45239
47016
  return false;
@@ -45241,7 +47018,7 @@ function textIsCurrent(existing, row, workspaceSlug, relPath) {
45241
47018
  }
45242
47019
 
45243
47020
  // src/lib/files/rejection-sync.ts
45244
- import { readFileSync as readFileSync26, statSync as statSync16 } from "node:fs";
47021
+ import { readFileSync as readFileSync28, statSync as statSync16 } from "node:fs";
45245
47022
 
45246
47023
  // src/lib/files/rejections-api.ts
45247
47024
  var RejectionsFeedSchema = exports_external.object({
@@ -45374,7 +47151,7 @@ function settledDisposition(args) {
45374
47151
  return null;
45375
47152
  let bytes;
45376
47153
  try {
45377
- bytes = readFileSync26(absolute2);
47154
+ bytes = readFileSync28(absolute2);
45378
47155
  } catch {
45379
47156
  return null;
45380
47157
  }
@@ -45451,7 +47228,7 @@ function classifyApiError(err) {
45451
47228
  return { kind: "transient", status: err.status };
45452
47229
  }
45453
47230
  function hasExistingContent(workspacePath) {
45454
- if (!existsSync8(workspacePath))
47231
+ if (!existsSync9(workspacePath))
45455
47232
  return false;
45456
47233
  try {
45457
47234
  return readdirSync7(workspacePath, { withFileTypes: true }).some((e) => {
@@ -45460,7 +47237,7 @@ function hasExistingContent(workspacePath) {
45460
47237
  if (!e.isDirectory())
45461
47238
  return true;
45462
47239
  try {
45463
- return readdirSync7(join17(workspacePath, e.name)).length > 0;
47240
+ return readdirSync7(join18(workspacePath, e.name)).length > 0;
45464
47241
  } catch {
45465
47242
  return true;
45466
47243
  }
@@ -45490,7 +47267,7 @@ function unlinkIfProvablyOurs(workspaceSlug, strategy, entry2, suppressor) {
45490
47267
  }
45491
47268
  let raw;
45492
47269
  try {
45493
- raw = readFileSync27(target, "utf8");
47270
+ raw = readFileSync29(target, "utf8");
45494
47271
  } catch (err) {
45495
47272
  if (err?.code === "ENOENT") {
45496
47273
  return { didUnlink: false };
@@ -45508,7 +47285,7 @@ function unlinkIfProvablyOurs(workspaceSlug, strategy, entry2, suppressor) {
45508
47285
  }
45509
47286
  try {
45510
47287
  suppressor?.registerDelete(target);
45511
- unlinkSync8(target);
47288
+ unlinkSync9(target);
45512
47289
  return { didUnlink: true };
45513
47290
  } catch (err) {
45514
47291
  if (err?.code === "ENOENT") {
@@ -45517,6 +47294,122 @@ function unlinkIfProvablyOurs(workspaceSlug, strategy, entry2, suppressor) {
45517
47294
  throw err;
45518
47295
  }
45519
47296
  }
47297
+ function dueDocumentRefreshMarkers(workspaceSlug, now) {
47298
+ try {
47299
+ return loadPermanentDeletionJournal(workspaceSlug).refresh.filter((marker) => marker.kind === "document" && (marker.retry_after === undefined || new Date(marker.retry_after).getTime() <= now.getTime()));
47300
+ } catch {
47301
+ return null;
47302
+ }
47303
+ }
47304
+ async function replayPermanentDocuments(args) {
47305
+ const rows = [];
47306
+ let cursor = null;
47307
+ let resultingCursor = args.savedCursor;
47308
+ while (true) {
47309
+ const page = await syncEntity({
47310
+ strategy: args.strategy,
47311
+ baseUrl: args.baseUrl,
47312
+ pat: args.pat,
47313
+ cursor,
47314
+ fetchImpl: args.fetchImpl
47315
+ });
47316
+ rows.push(...page.rows);
47317
+ if (page.next_cursor === null)
47318
+ break;
47319
+ resultingCursor = page.next_cursor;
47320
+ cursor = page.next_cursor;
47321
+ }
47322
+ let state = args.state;
47323
+ const completed = [];
47324
+ let completePass = true;
47325
+ let restoresDeferred = 0;
47326
+ for (const marker of args.markers) {
47327
+ const candidates = rows.filter((row) => row.deleted_at !== null && row.slug === marker.logical_key && row.permanent_delete === true && typeof row.purge_generation === "string" && typeof row.retirement_sequence === "number");
47328
+ candidates.sort((a, b) => b.retirement_sequence - a.retirement_sequence);
47329
+ const candidate = candidates[0];
47330
+ const ambiguousLatest = candidate !== undefined && candidates.some((row) => row.retirement_sequence === candidate.retirement_sequence && row.purge_generation !== candidate.purge_generation);
47331
+ const stateBefore = state;
47332
+ const outcome = await withPermanentDeletionLock("document", marker.logical_key, async () => {
47333
+ const namespace = await getCurrentPermanentNamespace({
47334
+ baseUrl: args.baseUrl,
47335
+ pat: args.pat,
47336
+ entityKind: "document",
47337
+ logicalKey: marker.logical_key,
47338
+ fetchImpl: args.fetchImpl
47339
+ });
47340
+ if (!candidate || ambiguousLatest || namespace.latest_retirement === null || namespace.latest_retirement.purge_generation !== candidate.purge_generation || namespace.latest_retirement.retirement_sequence !== candidate.retirement_sequence) {
47341
+ markRetentionMissing({
47342
+ workspaceSlug: args.workspaceSlug,
47343
+ kind: "document",
47344
+ logicalKey: marker.logical_key,
47345
+ now: args.now
47346
+ });
47347
+ args.output.warn(`documents/${marker.logical_key}: permanent-delete replay could not prove one unambiguous current retained tombstone; creates remain fenced and the replay will retry.`);
47348
+ return { state: stateBefore, complete: false };
47349
+ }
47350
+ const liveOwnerId = namespace.live_owner?.id ?? null;
47351
+ const disposed = await disposePermanentDocument({
47352
+ workspaceSlug: args.workspaceSlug,
47353
+ strategy: args.strategy,
47354
+ tombstone: {
47355
+ id: candidate.id,
47356
+ logicalKey: marker.logical_key,
47357
+ deletedAt: candidate.deleted_at,
47358
+ purgeGeneration: candidate.purge_generation,
47359
+ retirementSequence: candidate.retirement_sequence
47360
+ },
47361
+ liveOwnerId,
47362
+ state: stateBefore,
47363
+ now: args.now,
47364
+ output: args.output,
47365
+ suppressor: args.suppressor
47366
+ });
47367
+ const committedState = args.commit && disposed.state !== stateBefore ? args.commit(stateBefore, disposed.state) : disposed.state;
47368
+ const current = await getCurrentPermanentNamespace({
47369
+ baseUrl: args.baseUrl,
47370
+ pat: args.pat,
47371
+ entityKind: "document",
47372
+ logicalKey: marker.logical_key,
47373
+ fetchImpl: args.fetchImpl
47374
+ });
47375
+ const complete = current.latest_retirement?.purge_generation === candidate.purge_generation && current.latest_retirement?.retirement_sequence === candidate.retirement_sequence && (current.live_owner?.id ?? null) === liveOwnerId;
47376
+ return { state: committedState, complete };
47377
+ });
47378
+ state = outcome.state;
47379
+ if (outcome.complete && candidate) {
47380
+ completed.push({ logicalKey: marker.logical_key, generation: candidate.purge_generation });
47381
+ } else {
47382
+ completePass = false;
47383
+ }
47384
+ }
47385
+ const counters = {
47386
+ fetched: rows.length,
47387
+ written: 0,
47388
+ deleted: 0,
47389
+ moved: 0,
47390
+ refused: 0
47391
+ };
47392
+ for (const row of rows) {
47393
+ if (row.deleted_at !== null && row.permanent_delete === true)
47394
+ continue;
47395
+ const outcome = applyRow(args.workspaceSlug, args.strategy, row, state, args.output, args.suppressor, args.now, args.isFrozen, args.loggedTombstoneSkips, args.recentUnlinks);
47396
+ state = outcome.state;
47397
+ if (outcome.action === "written")
47398
+ counters.written++;
47399
+ else if (outcome.action === "deleted")
47400
+ counters.deleted++;
47401
+ else if (outcome.action === "moved")
47402
+ counters.moved = (counters.moved ?? 0) + 1;
47403
+ else if (outcome.action === "slug_refused") {
47404
+ counters.refused = (counters.refused ?? 0) + 1;
47405
+ completePass = false;
47406
+ } else if (outcome.action === "restore_deferred") {
47407
+ restoresDeferred++;
47408
+ completePass = false;
47409
+ }
47410
+ }
47411
+ return { state, counters, cursor: resultingCursor, completed, completePass, restoresDeferred };
47412
+ }
45520
47413
  async function runSync(workspaceSlug, deps = {}) {
45521
47414
  const out = new Output({
45522
47415
  sink: deps.sink,
@@ -45636,13 +47529,66 @@ async function runSync(workspaceSlug, deps = {}) {
45636
47529
  committed = state;
45637
47530
  };
45638
47531
  const counters = {};
47532
+ let restoresDeferred = 0;
45639
47533
  try {
45640
47534
  for (const entityType of allEntityTypes()) {
45641
47535
  const strategy = requireStrategy(entityType);
45642
47536
  const startCursor = entry2.sync_cursors?.[entityType] ?? null;
45643
- let cursor = startCursor;
45644
- let lastAdvancedCursor = startCursor;
47537
+ if (entityType === "documents") {
47538
+ const markers = dueDocumentRefreshMarkers(slug, now());
47539
+ if (markers && markers.length > 0) {
47540
+ const replay = await replayPermanentDocuments({
47541
+ workspaceSlug: slug,
47542
+ baseUrl: entry2.api_base_url,
47543
+ pat,
47544
+ state,
47545
+ savedCursor: startCursor,
47546
+ strategy,
47547
+ markers,
47548
+ fetchImpl,
47549
+ output: out,
47550
+ suppressor: deps.suppressor,
47551
+ now,
47552
+ isFrozen: deps.isFrozen,
47553
+ loggedTombstoneSkips: deps.loggedTombstoneSkips,
47554
+ recentUnlinks: deps.recentUnlinks,
47555
+ commit: (before, after) => {
47556
+ const next = commitState(before, after);
47557
+ committed = next;
47558
+ return next;
47559
+ }
47560
+ });
47561
+ state = replay.state;
47562
+ commitPending();
47563
+ counters[entityType] = replay.counters;
47564
+ restoresDeferred += replay.restoresDeferred;
47565
+ if (replay.completePass) {
47566
+ updateSyncCursor(slug, entityType, replay.cursor);
47567
+ updateSyncHighWater(slug, entityType, null);
47568
+ for (const item of replay.completed) {
47569
+ clearFeedRefresh({
47570
+ workspaceSlug: slug,
47571
+ kind: "document",
47572
+ logicalKey: item.logicalKey,
47573
+ generation: item.generation
47574
+ });
47575
+ }
47576
+ }
47577
+ continue;
47578
+ }
47579
+ }
47580
+ const storedMark = entry2.sync_high_water?.[entityType] ?? null;
47581
+ let walkStart = deps.useHighWater === true && storedMark !== null ? storedMark : startCursor;
47582
+ let cursor = walkStart;
47583
+ let lastAdvancedCursor = walkStart;
47584
+ let finalHighWater = null;
45645
47585
  let cursorBlocked = false;
47586
+ let permanentEncountered = false;
47587
+ const permanentJournalBlocked = entityType === "documents" && dueDocumentRefreshMarkers(slug, now()) === null;
47588
+ if (permanentJournalBlocked) {
47589
+ cursorBlocked = true;
47590
+ out.warn(`documents: permanent deletion journal is invalid or unreadable; ordinary rows may apply, but the document cursor and permanent dispositions are held. Run \`plinth status\` for the journal path.`);
47591
+ }
45646
47592
  const ctr = {
45647
47593
  fetched: 0,
45648
47594
  written: 0,
@@ -45651,18 +47597,47 @@ async function runSync(workspaceSlug, deps = {}) {
45651
47597
  refused: 0
45652
47598
  };
45653
47599
  while (true) {
45654
- const page = await syncEntity({
45655
- strategy,
45656
- baseUrl: entry2.api_base_url,
45657
- pat,
45658
- cursor,
45659
- fetchImpl
45660
- });
47600
+ let page;
47601
+ try {
47602
+ page = await syncEntity({
47603
+ strategy,
47604
+ baseUrl: entry2.api_base_url,
47605
+ pat,
47606
+ cursor,
47607
+ fetchImpl
47608
+ });
47609
+ } catch (err) {
47610
+ if (cursor === walkStart && walkStart !== startCursor && err instanceof ApiError && err.status === 400) {
47611
+ updateSyncHighWater(slug, entityType, null);
47612
+ out.warn(`${entityType}: the stored high-water mark was refused; reading from the cursor instead.`);
47613
+ walkStart = startCursor;
47614
+ cursor = startCursor;
47615
+ lastAdvancedCursor = startCursor;
47616
+ continue;
47617
+ }
47618
+ throw err;
47619
+ }
45661
47620
  for (const row of page.rows) {
45662
47621
  ctr.fetched++;
47622
+ if (entityType === "documents" && row.deleted_at !== null && row.permanent_delete === true && row.purge_generation !== undefined) {
47623
+ if (permanentJournalBlocked) {
47624
+ cursorBlocked = true;
47625
+ continue;
47626
+ }
47627
+ requireFeedRefresh({
47628
+ workspaceSlug: slug,
47629
+ kind: "document",
47630
+ logicalKey: row.slug,
47631
+ requiredGeneration: row.purge_generation,
47632
+ now
47633
+ });
47634
+ permanentEncountered = true;
47635
+ cursorBlocked = true;
47636
+ continue;
47637
+ }
45663
47638
  let outcome;
45664
47639
  try {
45665
- outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen, deps.loggedTombstoneSkips);
47640
+ outcome = applyRow(slug, strategy, row, state, out, deps.suppressor, now, deps.isFrozen, deps.loggedTombstoneSkips, deps.recentUnlinks);
45666
47641
  } catch (err) {
45667
47642
  if (!(err instanceof MirrorSymlinkError))
45668
47643
  throw err;
@@ -45685,25 +47660,82 @@ async function runSync(workspaceSlug, deps = {}) {
45685
47660
  ctr.deleted++;
45686
47661
  else if (outcome.action === "moved")
45687
47662
  ctr.moved = (ctr.moved ?? 0) + 1;
45688
- else if (outcome.action === "slug_refused") {
47663
+ else if (outcome.action === "restore_deferred") {
47664
+ cursorBlocked = true;
47665
+ restoresDeferred++;
47666
+ } else if (outcome.action === "slug_refused") {
45689
47667
  ctr.refused = (ctr.refused ?? 0) + 1;
45690
47668
  cursorBlocked = true;
45691
47669
  }
45692
47670
  }
45693
47671
  commitPending();
45694
- if (page.next_cursor === null)
47672
+ if (page.next_cursor === null) {
47673
+ finalHighWater = page.high_water ?? null;
45695
47674
  break;
47675
+ }
45696
47676
  if (!cursorBlocked)
45697
47677
  lastAdvancedCursor = page.next_cursor;
45698
47678
  cursor = page.next_cursor;
45699
47679
  }
45700
47680
  counters[entityType] = ctr;
45701
- if (lastAdvancedCursor !== startCursor) {
47681
+ if (permanentEncountered && entityType === "documents") {
47682
+ const markers = dueDocumentRefreshMarkers(slug, now());
47683
+ if (!markers || markers.length === 0)
47684
+ continue;
47685
+ const replay = await replayPermanentDocuments({
47686
+ workspaceSlug: slug,
47687
+ baseUrl: entry2.api_base_url,
47688
+ pat,
47689
+ state,
47690
+ savedCursor: startCursor,
47691
+ strategy,
47692
+ markers,
47693
+ fetchImpl,
47694
+ output: out,
47695
+ suppressor: deps.suppressor,
47696
+ now,
47697
+ isFrozen: deps.isFrozen,
47698
+ loggedTombstoneSkips: deps.loggedTombstoneSkips,
47699
+ recentUnlinks: deps.recentUnlinks,
47700
+ commit: (before, after) => {
47701
+ const next = commitState(before, after);
47702
+ committed = next;
47703
+ return next;
47704
+ }
47705
+ });
47706
+ state = replay.state;
47707
+ commitPending();
47708
+ counters[entityType] = replay.counters;
47709
+ restoresDeferred += replay.restoresDeferred;
47710
+ if (replay.completePass) {
47711
+ updateSyncCursor(slug, entityType, replay.cursor);
47712
+ updateSyncHighWater(slug, entityType, null);
47713
+ for (const item of replay.completed) {
47714
+ clearFeedRefresh({
47715
+ workspaceSlug: slug,
47716
+ kind: "document",
47717
+ logicalKey: item.logicalKey,
47718
+ generation: item.generation
47719
+ });
47720
+ }
47721
+ }
47722
+ continue;
47723
+ }
47724
+ if (walkStart === startCursor && lastAdvancedCursor !== walkStart) {
45702
47725
  updateSyncCursor(slug, entityType, lastAdvancedCursor);
45703
47726
  }
47727
+ const mark = provenMark({
47728
+ clean: !cursorBlocked,
47729
+ finalHighWater,
47730
+ advancedTo: lastAdvancedCursor !== walkStart ? lastAdvancedCursor : null
47731
+ });
47732
+ if (mark !== null)
47733
+ updateSyncHighWater(slug, entityType, mark);
45704
47734
  }
45705
47735
  const needsDerivedTextRewalk = state.derived_text_version !== DERIVED_TEXT_VERSION;
45706
- const filesStartCursor = needsDerivedTextRewalk ? null : entry2.sync_cursors?.["files"] ?? null;
47736
+ const filesBaseCursor = needsDerivedTextRewalk ? null : entry2.sync_cursors?.["files"] ?? null;
47737
+ const filesMark = entry2.sync_high_water?.["files"] ?? null;
47738
+ const filesStartCursor = !needsDerivedTextRewalk && deps.useHighWater === true && filesMark !== null ? filesMark : filesBaseCursor;
45707
47739
  if (needsDerivedTextRewalk) {
45708
47740
  out.info("Re-reading the file index once to refresh extracted text (template changed).");
45709
47741
  }
@@ -45713,6 +47745,8 @@ async function runSync(workspaceSlug, deps = {}) {
45713
47745
  pat,
45714
47746
  state,
45715
47747
  cursor: filesStartCursor,
47748
+ baseCursor: filesBaseCursor,
47749
+ recentUnlinks: deps.recentUnlinks,
45716
47750
  fetchImpl,
45717
47751
  now,
45718
47752
  output: out,
@@ -45726,7 +47760,8 @@ async function runSync(workspaceSlug, deps = {}) {
45726
47760
  if (filesResult.state !== state) {
45727
47761
  state = filesResult.state;
45728
47762
  }
45729
- if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
47763
+ restoresDeferred += filesResult.restoresDeferred ?? 0;
47764
+ if (needsDerivedTextRewalk && filesResult.textUnknown === 0 && (filesResult.restoresDeferred ?? 0) === 0) {
45730
47765
  state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
45731
47766
  }
45732
47767
  counters["files"] = {
@@ -45735,9 +47770,28 @@ async function runSync(workspaceSlug, deps = {}) {
45735
47770
  deleted: filesResult.deleted + filesResult.textDeleted
45736
47771
  };
45737
47772
  commitPending();
45738
- if (filesResult.lastCursor !== filesStartCursor) {
47773
+ const filesWalkStart = filesResult.markRefused ? filesBaseCursor : filesStartCursor;
47774
+ const filesWalkedFromCursor = filesWalkStart === filesBaseCursor || filesResult.permanentReplayComplete === true;
47775
+ if (filesWalkedFromCursor && filesResult.lastCursor !== filesBaseCursor) {
45739
47776
  updateSyncCursor(slug, "files", filesResult.lastCursor);
45740
47777
  }
47778
+ if (filesResult.markRefused) {
47779
+ updateSyncHighWater(slug, "files", null);
47780
+ out.warn("files: the stored high-water mark was refused; reading from the cursor instead.");
47781
+ }
47782
+ if (filesResult.highWater)
47783
+ updateSyncHighWater(slug, "files", filesResult.highWater);
47784
+ if (filesResult.permanentReplayComplete) {
47785
+ updateSyncHighWater(slug, "files", null);
47786
+ for (const item of filesResult.permanentRefreshes ?? []) {
47787
+ clearFeedRefresh({
47788
+ workspaceSlug: slug,
47789
+ kind: "workspace_file",
47790
+ logicalKey: item.logicalKey,
47791
+ generation: item.generation
47792
+ });
47793
+ }
47794
+ }
45741
47795
  const rulesResult = await pullWorkspaceRules({
45742
47796
  workspaceSlug: slug,
45743
47797
  baseUrl: entry2.api_base_url,
@@ -45916,7 +47970,7 @@ async function runSync(workspaceSlug, deps = {}) {
45916
47970
  const totalRefused = Object.values(counters).reduce((sum, c) => sum + (c.refused ?? 0), 0);
45917
47971
  if (totalWritten === 0 && totalDeleted === 0 && totalMoved === 0 && totalRefused === 0) {
45918
47972
  out.success("Already up to date.");
45919
- return { exitCode: 0, applied: 0 };
47973
+ return { exitCode: 0, applied: 0, restoresDeferred };
45920
47974
  }
45921
47975
  const elapsed = ((now().getTime() - startedAt.getTime()) / 1000).toFixed(1);
45922
47976
  for (const [type, c] of Object.entries(counters)) {
@@ -45934,9 +47988,9 @@ async function runSync(workspaceSlug, deps = {}) {
45934
47988
  out.info(` ${type}: ${parts.join(", ")}`);
45935
47989
  }
45936
47990
  out.success(`Done in ${elapsed}s.`);
45937
- return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted };
47991
+ return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted, restoresDeferred };
45938
47992
  }
45939
- function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips) {
47993
+ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips, recentUnlinks) {
45940
47994
  if (isFrozen?.(strategy.type, row.id)) {
45941
47995
  return { state, action: "skipped" };
45942
47996
  }
@@ -45969,7 +48023,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
45969
48023
  if (!entry2) {
45970
48024
  let present2 = false;
45971
48025
  try {
45972
- present2 = existsSync8(entityFilePath(workspaceSlug, strategy.type, row.slug));
48026
+ present2 = existsSync9(entityFilePath(workspaceSlug, strategy.type, row.slug));
45973
48027
  } catch (err) {
45974
48028
  if (!(err instanceof MirrorPathError))
45975
48029
  throw err;
@@ -46048,7 +48102,22 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
46048
48102
  const writeSlug = relocation.writeSlug;
46049
48103
  if (existingEntry && existingEntry.updated_at === live.updated_at && relocation.kind === "not_needed") {
46050
48104
  const path5 = entityFilePath(workspaceSlug, strategy.type, live.slug);
46051
- if (existsSync8(path5)) {
48105
+ const present2 = existsSync9(path5);
48106
+ if (!present2) {
48107
+ const verdict = deferRestore(recentUnlinks, path5, live.updated_at, now().getTime());
48108
+ if (verdict.defer) {
48109
+ if (verdict.first) {
48110
+ out.log(entityRestoreDeferredLine({
48111
+ entityType: strategy.type,
48112
+ slug: live.slug,
48113
+ waitSeconds: RESTORE_DEFERRAL_MS / 1000
48114
+ }));
48115
+ }
48116
+ return { state, action: "restore_deferred" };
48117
+ }
48118
+ }
48119
+ if (present2) {
48120
+ presentAgain(recentUnlinks, path5);
46052
48121
  if (strategy.type === "artists") {
46053
48122
  const artistLive = live;
46054
48123
  state = applyArtistMemoryOnPull({
@@ -46075,7 +48144,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
46075
48144
  if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at && relocation.kind !== "moved") {
46076
48145
  let localRaw = null;
46077
48146
  try {
46078
- localRaw = readFileSync27(entityFilePath(workspaceSlug, strategy.type, writeSlug), "utf8");
48147
+ localRaw = readFileSync29(entityFilePath(workspaceSlug, strategy.type, writeSlug), "utf8");
46079
48148
  } catch {
46080
48149
  localRaw = null;
46081
48150
  }
@@ -46095,6 +48164,38 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
46095
48164
  }
46096
48165
  }
46097
48166
  }
48167
+ if (!suppressor && !existingEntry) {
48168
+ const target = entityFilePath(workspaceSlug, strategy.type, writeSlug);
48169
+ let localRaw = null;
48170
+ try {
48171
+ localRaw = lstatSync6(target).isFile() ? readFileSync29(target, "utf8") : null;
48172
+ } catch {
48173
+ localRaw = null;
48174
+ }
48175
+ if (localRaw !== null) {
48176
+ const hashStrategy = strategy;
48177
+ const incomingHashes = computeMirrorHashes(hashStrategy, renderMirrorWithBodyPreserve(workspaceSlug, strategy, live, writeSlug));
48178
+ let diverged = true;
48179
+ try {
48180
+ const localHashes = computeMirrorHashes(hashStrategy, localRaw);
48181
+ diverged = localHashes.bodyHash !== incomingHashes.bodyHash || localHashes.frontmatterHash !== incomingHashes.frontmatterHash;
48182
+ } catch {
48183
+ diverged = true;
48184
+ }
48185
+ if (diverged) {
48186
+ const stash = stashEntityConflictOnce({
48187
+ workspaceSlug,
48188
+ entityType: strategy.type,
48189
+ entitySlug: live.slug,
48190
+ timestamp: now(),
48191
+ contents: localRaw,
48192
+ side: "local"
48193
+ });
48194
+ const where = stash.reused ? `already stashed at` : `stashed it to`;
48195
+ out.warn(`Conflict on ${strategy.type}/${live.slug} → the local file there was not tracked for ` + `the incoming row and differed from it; ${where} ${stash.path} (cloud-wins on boot).`);
48196
+ }
48197
+ }
48198
+ }
46098
48199
  const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor, writeSlug);
46099
48200
  const hashes = computeMirrorHashes(strategy, content);
46100
48201
  let inode;
@@ -46233,7 +48334,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync18(p)
46233
48334
  return { state, changed: false };
46234
48335
  return { state: { ...state, entries: nextEntries }, changed: true };
46235
48336
  }
46236
- function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync28(p, "utf8")) {
48337
+ function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync30(p, "utf8")) {
46237
48338
  let changed = false;
46238
48339
  const nextEntries = {};
46239
48340
  for (const [entityType, bucket] of Object.entries(state.entries)) {
@@ -46264,7 +48365,7 @@ function makeWatchHandler(deps) {
46264
48365
  baseUrl,
46265
48366
  pat,
46266
48367
  spinner,
46267
- mutex,
48368
+ mutex: mutex2,
46268
48369
  getState,
46269
48370
  applyState,
46270
48371
  fetchImpl,
@@ -46308,7 +48409,7 @@ function makeWatchHandler(deps) {
46308
48409
  output?.log(fileDeleteSuppressedLine({ path: relPath, reason: "pull_echo" }));
46309
48410
  return;
46310
48411
  }
46311
- if (existsSync9(absolutePath)) {
48412
+ if (existsSync10(absolutePath)) {
46312
48413
  output?.log(fileDeleteSuppressedLine({ path: relPath, reason: "path_exists" }));
46313
48414
  return;
46314
48415
  }
@@ -46320,7 +48421,7 @@ function makeWatchHandler(deps) {
46320
48421
  return;
46321
48422
  }
46322
48423
  }
46323
- await mutex.run(`files:${relPath}`, async () => {
48424
+ await mutex2.run(`files:${relPath}`, async () => {
46324
48425
  const base2 = getState();
46325
48426
  const result = await pushFilesDelete({
46326
48427
  workspaceSlug,
@@ -46374,7 +48475,7 @@ function makeWatchHandler(deps) {
46374
48475
  spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
46375
48476
  return;
46376
48477
  }
46377
- const bytes = readFileSync28(absolutePath);
48478
+ const bytes = readFileSync30(absolutePath);
46378
48479
  const hash2 = sha256Bytes(bytes);
46379
48480
  if (suppressor?.shouldSuppressUpsert(absolutePath, {
46380
48481
  bodyHash: hash2,
@@ -46391,7 +48492,7 @@ function makeWatchHandler(deps) {
46391
48492
  return;
46392
48493
  }
46393
48494
  }
46394
- await mutex.run(`files:${relPath}`, async () => {
48495
+ await mutex2.run(`files:${relPath}`, async () => {
46395
48496
  const base2 = getState();
46396
48497
  const result = await pushFilesUpsert({
46397
48498
  workspaceSlug,
@@ -46438,7 +48539,7 @@ function makeWatchHandler(deps) {
46438
48539
  if (suppressor) {
46439
48540
  let raw = null;
46440
48541
  try {
46441
- raw = readFileSync28(absolutePath, "utf8");
48542
+ raw = readFileSync30(absolutePath, "utf8");
46442
48543
  } catch {
46443
48544
  raw = null;
46444
48545
  }
@@ -46450,7 +48551,7 @@ function makeWatchHandler(deps) {
46450
48551
  }
46451
48552
  }
46452
48553
  try {
46453
- await mutex.run(WORKSPACE_RULES_MUTEX_KEY, async () => {
48554
+ await mutex2.run(WORKSPACE_RULES_MUTEX_KEY, async () => {
46454
48555
  const base2 = getState();
46455
48556
  const result = await pushWorkspaceRules({
46456
48557
  workspaceSlug,
@@ -46477,7 +48578,7 @@ function makeWatchHandler(deps) {
46477
48578
  if (suppressor) {
46478
48579
  let raw = null;
46479
48580
  try {
46480
- raw = readFileSync28(absolutePath, "utf8");
48581
+ raw = readFileSync30(absolutePath, "utf8");
46481
48582
  } catch {
46482
48583
  raw = null;
46483
48584
  }
@@ -46489,7 +48590,7 @@ function makeWatchHandler(deps) {
46489
48590
  }
46490
48591
  }
46491
48592
  try {
46492
- await mutex.run(WORKSPACE_MEMORY_MUTEX_KEY, async () => {
48593
+ await mutex2.run(WORKSPACE_MEMORY_MUTEX_KEY, async () => {
46493
48594
  const base2 = getState();
46494
48595
  const result = await pushWorkspaceMemory({
46495
48596
  workspaceSlug,
@@ -46516,7 +48617,7 @@ function makeWatchHandler(deps) {
46516
48617
  if (suppressor) {
46517
48618
  let raw = null;
46518
48619
  try {
46519
- raw = readFileSync28(absolutePath, "utf8");
48620
+ raw = readFileSync30(absolutePath, "utf8");
46520
48621
  } catch {
46521
48622
  raw = null;
46522
48623
  }
@@ -46528,7 +48629,7 @@ function makeWatchHandler(deps) {
46528
48629
  }
46529
48630
  }
46530
48631
  try {
46531
- await mutex.run(USER_MEMORY_MUTEX_KEY, async () => {
48632
+ await mutex2.run(USER_MEMORY_MUTEX_KEY, async () => {
46532
48633
  const base2 = getState();
46533
48634
  const result = await pushUserMemory({
46534
48635
  workspaceSlug,
@@ -46555,7 +48656,7 @@ function makeWatchHandler(deps) {
46555
48656
  if (suppressor) {
46556
48657
  let raw = null;
46557
48658
  try {
46558
- raw = readFileSync28(absolutePath, "utf8");
48659
+ raw = readFileSync30(absolutePath, "utf8");
46559
48660
  } catch {
46560
48661
  raw = null;
46561
48662
  }
@@ -46567,7 +48668,7 @@ function makeWatchHandler(deps) {
46567
48668
  }
46568
48669
  }
46569
48670
  try {
46570
- await mutex.run(VOICE_MUTEX_KEY, async () => {
48671
+ await mutex2.run(VOICE_MUTEX_KEY, async () => {
46571
48672
  const base2 = getState();
46572
48673
  const result = await pushVoice({
46573
48674
  workspaceSlug,
@@ -46594,7 +48695,7 @@ function makeWatchHandler(deps) {
46594
48695
  if (suppressor) {
46595
48696
  let raw = null;
46596
48697
  try {
46597
- raw = readFileSync28(absolutePath, "utf8");
48698
+ raw = readFileSync30(absolutePath, "utf8");
46598
48699
  } catch {
46599
48700
  raw = null;
46600
48701
  }
@@ -46617,7 +48718,7 @@ function makeWatchHandler(deps) {
46617
48718
  now,
46618
48719
  output,
46619
48720
  suppressor,
46620
- mutex
48721
+ mutex: mutex2
46621
48722
  });
46622
48723
  applyState(base2, result.state);
46623
48724
  reportArtistMemoryOutcome(spinner, workspaceSlug, result.state, result.outcome);
@@ -46640,7 +48741,7 @@ function makeWatchHandler(deps) {
46640
48741
  const strategy = getStrategy(classifiedFrom.entityType);
46641
48742
  let toRaw = null;
46642
48743
  try {
46643
- toRaw = readFileSync28(event.toPath, "utf8");
48744
+ toRaw = readFileSync30(event.toPath, "utf8");
46644
48745
  } catch {
46645
48746
  toRaw = null;
46646
48747
  }
@@ -46667,7 +48768,7 @@ function makeWatchHandler(deps) {
46667
48768
  deps: {
46668
48769
  fetchImpl,
46669
48770
  now,
46670
- mutex,
48771
+ mutex: mutex2,
46671
48772
  output,
46672
48773
  echo: suppressor ? { watcher: "live", suppressor } : undefined
46673
48774
  }
@@ -46709,7 +48810,7 @@ function makeWatchHandler(deps) {
46709
48810
  if (event.kind === "upsert") {
46710
48811
  try {
46711
48812
  ownEcho = suppressor?.shouldSuppressUpsert(absolutePath, {
46712
- bodyHash: stableHash(readFileSync28(absolutePath, "utf8")),
48813
+ bodyHash: stableHash(readFileSync30(absolutePath, "utf8")),
46713
48814
  frontmatterHash: ""
46714
48815
  }) === true;
46715
48816
  } catch {
@@ -46764,7 +48865,7 @@ function makeWatchHandler(deps) {
46764
48865
  if (strategy) {
46765
48866
  let raw = null;
46766
48867
  try {
46767
- raw = readFileSync28(absolutePath, "utf8");
48868
+ raw = readFileSync30(absolutePath, "utf8");
46768
48869
  } catch {
46769
48870
  raw = null;
46770
48871
  }
@@ -46774,7 +48875,7 @@ function makeWatchHandler(deps) {
46774
48875
  }
46775
48876
  }
46776
48877
  }
46777
- if (event.kind === "unlink" && existsSync9(absolutePath)) {
48878
+ if (event.kind === "unlink" && existsSync10(absolutePath)) {
46778
48879
  output?.log(entityDeleteSuppressedLine({
46779
48880
  entityType: classified.entityType,
46780
48881
  slug: classified.entitySlug,
@@ -46813,7 +48914,7 @@ function makeWatchHandler(deps) {
46813
48914
  deps: {
46814
48915
  fetchImpl,
46815
48916
  now,
46816
- mutex,
48917
+ mutex: mutex2,
46817
48918
  output,
46818
48919
  suppressor,
46819
48920
  echo: suppressor ? { watcher: "live", suppressor } : undefined
@@ -47235,7 +49336,7 @@ async function runStart(workspaceSlug, deps = {}) {
47235
49336
  }
47236
49337
  const spinner = (deps.spinnerFactory ?? (() => createSpinner({ noColor: deps.noColor })))();
47237
49338
  spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
47238
- const mutex = new PerIdMutex;
49339
+ const mutex2 = new PerIdMutex;
47239
49340
  const bootDrain = await drainResolvedBatches({
47240
49341
  echo: { watcher: "none" },
47241
49342
  workspaceSlug: slug,
@@ -47247,7 +49348,7 @@ async function runStart(workspaceSlug, deps = {}) {
47247
49348
  },
47248
49349
  fetchImpl,
47249
49350
  now: deps.now,
47250
- mutex,
49351
+ mutex: mutex2,
47251
49352
  output: out
47252
49353
  });
47253
49354
  if (bootDrain.applied > 0 || bootDrain.discarded > 0) {
@@ -47265,7 +49366,7 @@ async function runStart(workspaceSlug, deps = {}) {
47265
49366
  },
47266
49367
  fetchImpl,
47267
49368
  now: deps.now,
47268
- mutex,
49369
+ mutex: mutex2,
47269
49370
  output: out
47270
49371
  });
47271
49372
  if (bootUnsynced.attempted > 0) {
@@ -47317,7 +49418,7 @@ async function runStart(workspaceSlug, deps = {}) {
47317
49418
  applyState: (base2, next) => {
47318
49419
  state = rebaseState(state, base2, next);
47319
49420
  },
47320
- mutex,
49421
+ mutex: mutex2,
47321
49422
  fetchImpl,
47322
49423
  now: deps.now,
47323
49424
  output: out,
@@ -47381,7 +49482,7 @@ async function runStart(workspaceSlug, deps = {}) {
47381
49482
  applyState: (base2, next) => {
47382
49483
  state = rebaseState(state, base2, next);
47383
49484
  },
47384
- mutex,
49485
+ mutex: mutex2,
47385
49486
  fetchImpl,
47386
49487
  now: deps.now,
47387
49488
  output: out,
@@ -47433,7 +49534,7 @@ async function runStart(workspaceSlug, deps = {}) {
47433
49534
  baseUrl: entry2.api_base_url,
47434
49535
  pat,
47435
49536
  spinner,
47436
- mutex,
49537
+ mutex: mutex2,
47437
49538
  getState: () => state,
47438
49539
  applyState: (base2, next) => {
47439
49540
  state = rebaseState(state, base2, next);
@@ -47453,13 +49554,18 @@ async function runStart(workspaceSlug, deps = {}) {
47453
49554
  return { exitCode: 0 };
47454
49555
  }
47455
49556
  writeDaemonPid(slug, process.pid, (deps.now ?? (() => new Date))());
47456
- const watcher = startWatcher({ workspaceSlug: slug, getStateEntry, classifyPath }, handler);
49557
+ const ambient = createAmbientPollWiring({
49558
+ now: () => (deps.now?.() ?? new Date).getTime(),
49559
+ baseReadIntervalMs: baseReadIntervalMs()
49560
+ });
49561
+ const watcher = startWatcher({ workspaceSlug: slug, getStateEntry, classifyPath, onUnlink: ambient.onUnlink }, handler);
47457
49562
  const quietSink = discardSink();
47458
49563
  let draining = false;
47459
49564
  let lastHeartbeatMs = 0;
47460
49565
  const pullLoop = new PullLoop({
47461
49566
  runOnce: async () => {
47462
- const result = await runSync(slug, {
49567
+ const result = await ambient.poll((sc2) => runSync(slug, {
49568
+ ...sc2,
47463
49569
  fetchImpl,
47464
49570
  keychain,
47465
49571
  now: deps.now,
@@ -47471,7 +49577,7 @@ async function runStart(workspaceSlug, deps = {}) {
47471
49577
  isFileFrozen,
47472
49578
  loggedTombstoneSkips,
47473
49579
  loggedStashNotices
47474
- });
49580
+ }));
47475
49581
  if (result.exitCode === 0) {
47476
49582
  const applied = result.applied ?? 0;
47477
49583
  if (applied > 0) {
@@ -47497,7 +49603,7 @@ async function runStart(workspaceSlug, deps = {}) {
47497
49603
  workspaceSlug: slug,
47498
49604
  baseUrl: entry2.api_base_url,
47499
49605
  pat,
47500
- mutex,
49606
+ mutex: mutex2,
47501
49607
  commitState,
47502
49608
  getState: () => state,
47503
49609
  setState: setDaemonState,
@@ -47525,7 +49631,7 @@ async function runStart(workspaceSlug, deps = {}) {
47525
49631
  },
47526
49632
  fetchImpl,
47527
49633
  now: deps.now,
47528
- mutex,
49634
+ mutex: mutex2,
47529
49635
  output: out
47530
49636
  });
47531
49637
  if (drained.applied > 0 || drained.discarded > 0) {
@@ -47543,7 +49649,7 @@ async function runStart(workspaceSlug, deps = {}) {
47543
49649
  },
47544
49650
  fetchImpl,
47545
49651
  now: deps.now,
47546
- mutex,
49652
+ mutex: mutex2,
47547
49653
  output: out
47548
49654
  });
47549
49655
  if (redriven.resolved > 0) {
@@ -47555,7 +49661,7 @@ async function runStart(workspaceSlug, deps = {}) {
47555
49661
  spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
47556
49662
  }
47557
49663
  let rulesBase = state;
47558
- const rulesRecon = await mutex.run(WORKSPACE_RULES_MUTEX_KEY, () => {
49664
+ const rulesRecon = await mutex2.run(WORKSPACE_RULES_MUTEX_KEY, () => {
47559
49665
  rulesBase = state;
47560
49666
  return reconcileWorkspaceRules({
47561
49667
  workspaceSlug: slug,
@@ -47571,7 +49677,7 @@ async function runStart(workspaceSlug, deps = {}) {
47571
49677
  state = rebaseState(state, rulesBase, rulesRecon.state);
47572
49678
  reportSingletonReconcile(spinner, slug, state, WORKSPACE_RULES_FILENAME, rulesRecon.outcome);
47573
49679
  let memoryBase = state;
47574
- const memoryRecon = await mutex.run(WORKSPACE_MEMORY_MUTEX_KEY, () => {
49680
+ const memoryRecon = await mutex2.run(WORKSPACE_MEMORY_MUTEX_KEY, () => {
47575
49681
  memoryBase = state;
47576
49682
  return reconcileWorkspaceMemory({
47577
49683
  workspaceSlug: slug,
@@ -47587,7 +49693,7 @@ async function runStart(workspaceSlug, deps = {}) {
47587
49693
  state = rebaseState(state, memoryBase, memoryRecon.state);
47588
49694
  reportSingletonReconcile(spinner, slug, state, WORKSPACE_MEMORY_FILENAME, memoryRecon.outcome);
47589
49695
  let userMemoryBase = state;
47590
- const userMemoryRecon = await mutex.run(USER_MEMORY_MUTEX_KEY, () => {
49696
+ const userMemoryRecon = await mutex2.run(USER_MEMORY_MUTEX_KEY, () => {
47591
49697
  userMemoryBase = state;
47592
49698
  return reconcileUserMemory({
47593
49699
  workspaceSlug: slug,
@@ -47604,7 +49710,7 @@ async function runStart(workspaceSlug, deps = {}) {
47604
49710
  reportSingletonReconcile(spinner, slug, state, USER_MEMORY_FILENAME, userMemoryRecon.outcome);
47605
49711
  try {
47606
49712
  let voiceBase = state;
47607
- const voiceRecon = await mutex.run(VOICE_MUTEX_KEY, () => {
49713
+ const voiceRecon = await mutex2.run(VOICE_MUTEX_KEY, () => {
47608
49714
  voiceBase = state;
47609
49715
  return reconcileVoice({
47610
49716
  workspaceSlug: slug,
@@ -47635,7 +49741,7 @@ async function runStart(workspaceSlug, deps = {}) {
47635
49741
  now: deps.now,
47636
49742
  output: out,
47637
49743
  suppressor,
47638
- mutex
49744
+ mutex: mutex2
47639
49745
  });
47640
49746
  state = rebaseState(state, memBase, memRecon.state);
47641
49747
  if (memRecon.pushed > 0) {
@@ -48291,12 +50397,12 @@ function assessContextWalls(workspaceSlug, deps = {}) {
48291
50397
 
48292
50398
  // src/lib/build-stamp.ts
48293
50399
  import { execFileSync as execFileSync4 } from "node:child_process";
48294
- import { existsSync as existsSync10, mkdirSync as mkdirSync11, readFileSync as readFileSync29, writeFileSync as writeFileSync8 } from "node:fs";
48295
- import { dirname as dirname13, join as join18 } from "node:path";
50400
+ import { existsSync as existsSync11, mkdirSync as mkdirSync12, readFileSync as readFileSync31, writeFileSync as writeFileSync8 } from "node:fs";
50401
+ import { dirname as dirname14, join as join19 } from "node:path";
48296
50402
  import { fileURLToPath } from "node:url";
48297
50403
  var BUILD_STAMP_FILENAME = "build-stamp.json";
48298
50404
  function stampPath(distDir) {
48299
- return join18(distDir, BUILD_STAMP_FILENAME);
50405
+ return join19(distDir, BUILD_STAMP_FILENAME);
48300
50406
  }
48301
50407
  function readHead(repoDir) {
48302
50408
  try {
@@ -48315,7 +50421,7 @@ function readHead(repoDir) {
48315
50421
  }
48316
50422
  function readBuildStamp(distDir) {
48317
50423
  try {
48318
- const parsed = JSON.parse(readFileSync29(stampPath(distDir), "utf8"));
50424
+ const parsed = JSON.parse(readFileSync31(stampPath(distDir), "utf8"));
48319
50425
  if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
48320
50426
  return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
48321
50427
  }
@@ -48327,9 +50433,9 @@ function readBuildStamp(distDir) {
48327
50433
  function findRepoRoot(fromDir) {
48328
50434
  let dir = fromDir;
48329
50435
  for (;; ) {
48330
- if (existsSync10(join18(dir, ".git")))
50436
+ if (existsSync11(join19(dir, ".git")))
48331
50437
  return dir;
48332
- const parent = dirname13(dir);
50438
+ const parent = dirname14(dir);
48333
50439
  if (parent === dir)
48334
50440
  return null;
48335
50441
  dir = parent;
@@ -48353,7 +50459,7 @@ function checkBuildStaleness(deps = {}) {
48353
50459
  const metaUrl = deps.metaUrl ?? import.meta.url;
48354
50460
  let distDir;
48355
50461
  try {
48356
- distDir = dirname13(fileURLToPath(metaUrl));
50462
+ distDir = dirname14(fileURLToPath(metaUrl));
48357
50463
  } catch {
48358
50464
  return { kind: "unknown" };
48359
50465
  }
@@ -48376,12 +50482,12 @@ function checkBuildStaleness(deps = {}) {
48376
50482
  }
48377
50483
 
48378
50484
  // src/lib/memory-links.ts
48379
- import { readFileSync as readFileSync30 } from "node:fs";
50485
+ import { readFileSync as readFileSync32 } from "node:fs";
48380
50486
  import { relative as relative15, sep as sep8 } from "node:path";
48381
50487
  var MEMORY_FILE_RE = /^(workspace-memory\.md|user-memory\.md|artists\/[^/]+\/memory\.md|memory\/[^/]+\.md|memory\/private\/[^/]+\.md)$/i;
48382
50488
  function scanMemoryLinks(deps) {
48383
50489
  const listFiles = deps.listFiles ?? ((r) => walkSubtreeFiles(r, isVcsInternal));
48384
- const readFile = deps.readFile ?? ((p) => readFileSync30(p, "utf8"));
50490
+ const readFile = deps.readFile ?? ((p) => readFileSync32(p, "utf8"));
48385
50491
  let root;
48386
50492
  let absPaths;
48387
50493
  try {
@@ -48425,7 +50531,7 @@ function scanMemoryLinks(deps) {
48425
50531
  init_output();
48426
50532
 
48427
50533
  // src/lib/pending-writes.ts
48428
- import { readFileSync as readFileSync31, statSync as statSync19 } from "node:fs";
50534
+ import { readFileSync as readFileSync33, statSync as statSync19 } from "node:fs";
48429
50535
  import { relative as relative16 } from "node:path";
48430
50536
  var import_gray_matter15 = __toESM(require_gray_matter(), 1);
48431
50537
  var EMPTY3 = { writes: [], count: 0 };
@@ -48473,7 +50579,7 @@ function scanPendingWrites(args) {
48473
50579
  continue;
48474
50580
  let hash2;
48475
50581
  try {
48476
- hash2 = sha256Bytes(readFileSync31(absolutePath));
50582
+ hash2 = sha256Bytes(readFileSync33(absolutePath));
48477
50583
  } catch {
48478
50584
  continue;
48479
50585
  }
@@ -48504,7 +50610,7 @@ function scanPendingWrites(args) {
48504
50610
  continue;
48505
50611
  let content;
48506
50612
  try {
48507
- content = readFileSync31(absolutePath, "utf8");
50613
+ content = readFileSync33(absolutePath, "utf8");
48508
50614
  } catch {
48509
50615
  continue;
48510
50616
  }
@@ -48834,6 +50940,33 @@ async function runStatus(args, deps = {}) {
48834
50940
  }
48835
50941
  }
48836
50942
  out.info("");
50943
+ let deletionJournal = null;
50944
+ let deletionJournalError = null;
50945
+ try {
50946
+ deletionJournal = loadPermanentDeletionJournal(slug);
50947
+ } catch (error52) {
50948
+ deletionJournalError = error52 instanceof Error ? error52.message : String(error52);
50949
+ }
50950
+ if (deletionJournalError) {
50951
+ out.info(row("Deletion", "BLOCKED — permanent deletion journal needs recovery."));
50952
+ out.info(cont(`${permanentDeletionJournalPath(slug)}: ${deletionJournalError}`));
50953
+ } else {
50954
+ const pending2 = deletionJournal.records.filter((record2) => record2.disposition === "pending");
50955
+ const refresh = deletionJournal.refresh;
50956
+ const operations = deletionJournal.records.filter((record2) => record2.operation?.state === "pending" || record2.operation?.state === "transport_unknown");
50957
+ if (pending2.length === 0 && refresh.length === 0 && operations.length === 0) {
50958
+ out.info(row("Deletion", "No permanent deletion reconciliation pending."));
50959
+ } else {
50960
+ out.info(row("Deletion", `${pending2.length} disposition${pending2.length === 1 ? "" : "s"} pending; ` + `${refresh.length} feed refresh${refresh.length === 1 ? "" : "es"} required; ` + `${operations.length} create operation${operations.length === 1 ? "" : "s"} unresolved.`));
50961
+ for (const marker of refresh.slice(0, 5)) {
50962
+ out.info(cont(`${marker.kind}:${marker.logical_key} — ${marker.state}` + (marker.retry_after ? `, retry after ${marker.retry_after}` : "")));
50963
+ }
50964
+ for (const record2 of operations.slice(0, 5)) {
50965
+ out.info(cont(`${record2.kind}:${record2.logical_key} — create ${record2.operation.state}`));
50966
+ }
50967
+ }
50968
+ }
50969
+ out.info("");
48837
50970
  const held = listPendingBatches(slug).filter((b) => b.status === "pending");
48838
50971
  if (held.length === 0) {
48839
50972
  out.info(row("Pending", "No destructive batches held."));
@@ -49388,7 +51521,7 @@ var versionCommand = defineCommand({
49388
51521
  });
49389
51522
 
49390
51523
  // src/commands/voice-gate.ts
49391
- import { readFileSync as readFileSync34 } from "node:fs";
51524
+ import { readFileSync as readFileSync36 } from "node:fs";
49392
51525
 
49393
51526
  // src/lib/voice-gate/api.ts
49394
51527
  var VoiceGateSurfaces = [
@@ -49490,15 +51623,15 @@ async function callVoiceGate(req) {
49490
51623
  }
49491
51624
 
49492
51625
  // src/lib/voice-gate/attempts.ts
49493
- import { createHash as createHash5 } from "node:crypto";
51626
+ import { createHash as createHash7 } from "node:crypto";
49494
51627
  import {
49495
- mkdirSync as mkdirSync12,
49496
- readFileSync as readFileSync32,
49497
- renameSync as renameSync8,
49498
- unlinkSync as unlinkSync9,
51628
+ mkdirSync as mkdirSync13,
51629
+ readFileSync as readFileSync34,
51630
+ renameSync as renameSync9,
51631
+ unlinkSync as unlinkSync10,
49499
51632
  writeFileSync as writeFileSync9
49500
51633
  } from "node:fs";
49501
- import { join as join19 } from "node:path";
51634
+ import { join as join20 } from "node:path";
49502
51635
  var MAX_REGENS = 2;
49503
51636
  var ENTRY_TTL_MS = 24 * 60 * 60 * 1000;
49504
51637
  var AttemptEntrySchema = exports_external.object({
@@ -49511,20 +51644,20 @@ var AttemptsFileSchema = exports_external.object({
49511
51644
  entries: exports_external.record(exports_external.string(), AttemptEntrySchema)
49512
51645
  });
49513
51646
  function attemptsDir(workspaceSlug) {
49514
- return join19(plinthHome(), "_voice_gate", workspaceSlug);
51647
+ return join20(plinthHome(), "_voice_gate", workspaceSlug);
49515
51648
  }
49516
51649
  function attemptsPath(workspaceSlug) {
49517
- return join19(attemptsDir(workspaceSlug), "attempts.json");
51650
+ return join20(attemptsDir(workspaceSlug), "attempts.json");
49518
51651
  }
49519
51652
  function attemptKey(identity) {
49520
- return createHash5("sha256").update(identity, "utf8").digest("hex").slice(0, 32);
51653
+ return createHash7("sha256").update(identity, "utf8").digest("hex").slice(0, 32);
49521
51654
  }
49522
51655
  function emptyFile() {
49523
51656
  return { version: 1, entries: {} };
49524
51657
  }
49525
51658
  function load2(workspaceSlug) {
49526
51659
  try {
49527
- const raw = readFileSync32(attemptsPath(workspaceSlug), "utf8");
51660
+ const raw = readFileSync34(attemptsPath(workspaceSlug), "utf8");
49528
51661
  const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
49529
51662
  return parsed.success ? parsed.data : emptyFile();
49530
51663
  } catch {
@@ -49544,14 +51677,14 @@ function save(workspaceSlug, file2) {
49544
51677
  const dir = attemptsDir(workspaceSlug);
49545
51678
  const target = attemptsPath(workspaceSlug);
49546
51679
  const tmp = `${target}.${process.pid}.${tmpCounter2++}.tmp`;
49547
- mkdirSync12(dir, { recursive: true, mode: 448 });
51680
+ mkdirSync13(dir, { recursive: true, mode: 448 });
49548
51681
  writeFileSync9(tmp, `${JSON.stringify(file2, null, 2)}
49549
51682
  `, { mode: 384 });
49550
51683
  try {
49551
- renameSync8(tmp, target);
51684
+ renameSync9(tmp, target);
49552
51685
  } catch (err) {
49553
51686
  try {
49554
- unlinkSync9(tmp);
51687
+ unlinkSync10(tmp);
49555
51688
  } catch {}
49556
51689
  throw err;
49557
51690
  }
@@ -49687,7 +51820,7 @@ function denyUngated(detail) {
49687
51820
  }
49688
51821
 
49689
51822
  // src/lib/voice-gate/payload.ts
49690
- import { readFileSync as readFileSync33, statSync as statSync21 } from "node:fs";
51823
+ import { readFileSync as readFileSync35, statSync as statSync21 } from "node:fs";
49691
51824
  import { homedir as homedir2 } from "node:os";
49692
51825
  import { isAbsolute as isAbsolute4, resolve as resolve6 } from "node:path";
49693
51826
  var MAX_BODY_BYTES = 256 * 1024;
@@ -49848,7 +51981,7 @@ function defaultReadBodyFile(path5) {
49848
51981
  if (stat4.size > MAX_BODY_BYTES) {
49849
51982
  throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
49850
51983
  }
49851
- return readFileSync33(path5, "utf8");
51984
+ return readFileSync35(path5, "utf8");
49852
51985
  }
49853
51986
  function parseRecipients(...raw) {
49854
51987
  const all = raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
@@ -50208,7 +52341,7 @@ async function runVoiceGateDirect(args, deps = {}) {
50208
52341
  let text;
50209
52342
  if (args.file) {
50210
52343
  try {
50211
- text = readFileSync34(args.file, "utf8");
52344
+ text = readFileSync36(args.file, "utf8");
50212
52345
  } catch (err) {
50213
52346
  const msg = err instanceof Error ? err.message : "unknown error";
50214
52347
  out.error(`Could not read ${args.file}: ${msg}`);