@taprootio/site-authoring 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/verbs/pull.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  getNavigation,
3
3
  getPage,
4
4
  getSettingsGroup,
5
+ getSiteRedirectMap,
5
6
  listSitePages,
6
7
  PAGE_STATUS_DELETED,
7
8
  PAGE_STATUS_DRAFT,
@@ -10,8 +11,11 @@ import {
10
11
  withRefusalGuidance,
11
12
  } from "../api.js";
12
13
  import { REFUSAL_CAPABILITY_MISSING, REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
13
- import { SiteAuthoringError } from "../errors.js";
14
+ import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
14
15
  import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
16
+ import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
17
+ import { projectRedirectMapForWorkspace, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
18
+ import { redirectsManifestEntry } from "../redirects-workspace.js";
15
19
  import { boundedList, openSession, successResult } from "../session.js";
16
20
  import {
17
21
  projectSettingsGroup,
@@ -29,10 +33,12 @@ import {
29
33
  inspectArtifactSiteBinding,
30
34
  inspectManifestSiteBinding,
31
35
  internalPageBaselineFile,
36
+ internalPageObservedRevisionFile,
32
37
  MANIFEST_FILE_NAME,
33
38
  MANIFEST_VERSION,
34
39
  MEDIA_MANIFEST_FILE_NAME,
35
40
  NAVIGATION_FILE_NAME,
41
+ normalizePageBodyRevision,
36
42
  normalizePagePath,
37
43
  PAGE_READ_ONLY_REASON_SYSTEM_404,
38
44
  PAGE_SOURCE_EXTENSIONS,
@@ -43,6 +49,7 @@ import {
43
49
  pageSourceFormat,
44
50
  pageSourceRegistry,
45
51
  PAGES_DIRECTORY,
52
+ readObservedPageRecord,
46
53
  readWorkspaceFile,
47
54
  readWorkspaceJson,
48
55
  SETTINGS_DIRECTORY,
@@ -128,9 +135,39 @@ function pullConflict(conflicts) {
128
135
  + `was changed, and the site's version is preserved at '${first.baselineFile}'. Either run `
129
136
  + `'taproot-site pages push ${selector}' to make the site match '${first.file}', or delete '${first.file}' `
130
137
  + `and pull again to adopt the site's version as this page's source.`
131
- + (remaining > 0 ? ` ${remaining} other tracked page(s) are in the same state.` : ""),
138
+ + (remaining > 0 ? ` ${remaining} other tracked page(s) are in the same state.` : "")
139
+ // Last, so the diagnostic bounds itself: the message is cut at
140
+ // LIMITS.diagnosticScalars, and twenty long paths must never cut off the
141
+ // recovery the refusal exists to state.
142
+ + ` ${describeDifferenceSummary(first.differences)}`,
132
143
  { field: first.file, alternatives: conflicts.map((entry) => entry.file) },
133
- );
144
+ // Deliberately not `?? []`: an empty list is the claim "compared, and the
145
+ // body is identical", which is what an agent reads as "look at the title,
146
+ // path, or description instead". A comparison that could not be made at all
147
+ // must leave the field absent rather than make that claim.
148
+ ).withDifferences(reportableDifferencePaths(first.differences));
149
+ }
150
+
151
+ /**
152
+ * One sentence naming what actually moved, for the conflict that reports it.
153
+ *
154
+ * "This page changed on the site" is true and useless: on the SHY production
155
+ * home page the whole change was an image's `src` and `urls`, re-projected on
156
+ * every read, and nothing in the refusal said so. The paths do not decide
157
+ * anything — the revision already did — but they are what tells an operator
158
+ * whether the site gained real content or merely a new signed URL.
159
+ */
160
+ function describeDifferenceSummary(differences) {
161
+ if (differences === undefined) {
162
+ return "This workspace kept no copy of the site's previous version, so there is nothing to compare it against.";
163
+ }
164
+ if (differences.paths.length === 0) {
165
+ return differences.truncated
166
+ ? "The two documents were too large to compare completely; no difference was found in the part compared."
167
+ : "The site's document is identical to the copy this workspace last reconciled with, so the change is in the "
168
+ + "page's title, path, or description rather than its body.";
169
+ }
170
+ return `Differs at: ${differences.paths.join(", ")}${differences.truncated ? ", and more" : ""}.`;
134
171
  }
135
172
 
136
173
  /**
@@ -154,6 +191,63 @@ function holdsRemoteDocument(sourceBytes, remoteHash) {
154
191
  }
155
192
  }
156
193
 
194
+ /** A page document, or `undefined` when the bytes are not one. */
195
+ function parsePageDocument(bytes) {
196
+ try {
197
+ const parsed = JSON.parse(bytes.toString("utf8"));
198
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The document this workspace last reconciled with, if it still holds one.
206
+ *
207
+ * Two places can hold it. Internal state holds it for a page whose own source
208
+ * could not be rewritten from the site's document — every Markdown-tracked
209
+ * page, and any ProseMirror source carrying unpushed edits. For a ProseMirror
210
+ * source pull last refreshed, the source file *is* what pull wrote, so it is
211
+ * the previous version; but only while it has not been edited since, because
212
+ * an edited file is a mixture of local and remote work and diffing it would
213
+ * attribute the author's own changes to the site.
214
+ *
215
+ * Every failure is "nothing to compare against", the way
216
+ * `readObservedPageRevision` treats damaged internal state. This read exists to
217
+ * make a message more legible and decides nothing; pull writes the preserved
218
+ * body pretty-printed and under no per-file cap of its own, so a body large
219
+ * enough to trip `documentBytes` on the way back in is reachable — and a throw
220
+ * there would replace the `pages.pull_conflict` the caller is raising (skipping
221
+ * the observed-revision write that keeps the recovery push reachable), or fail
222
+ * an ordinary non-conflicting refresh outright. The conflict is still raised,
223
+ * and reports that this workspace kept no copy of the previous version.
224
+ */
225
+ async function readPreviousRemoteDocument({ workspaceDir, baselineFile, sourceBytes, markdown, localChanged }) {
226
+ try {
227
+ if (await workspaceFileExists(workspaceDir, baselineFile)) {
228
+ const bytes = await readWorkspaceFile(workspaceDir, baselineFile, WORKSPACE_LIMITS.documentBytes);
229
+ const preserved = parsePageDocument(bytes);
230
+ if (preserved !== undefined) return preserved;
231
+ }
232
+ } catch {
233
+ return undefined;
234
+ }
235
+ if (markdown || localChanged) return undefined;
236
+ return parsePageDocument(sourceBytes);
237
+ }
238
+
239
+ /** The JSON paths at which the site's document moved, when that is knowable. */
240
+ async function describeRemoteMovement({ workspaceDir, baselineFile, sourceBytes, markdown, localChanged, body }) {
241
+ const previous = await readPreviousRemoteDocument({
242
+ workspaceDir,
243
+ baselineFile,
244
+ sourceBytes,
245
+ markdown,
246
+ localChanged,
247
+ });
248
+ return previous === undefined ? undefined : describeJsonDifferences(previous, body);
249
+ }
250
+
157
251
  function bodyStatusFor(summary) {
158
252
  if (summary.hasDraft) return PAGE_STATUS_DRAFT;
159
253
  if (summary.status !== PAGE_STATUS_UNKNOWN) return summary.status;
@@ -296,6 +390,9 @@ async function resolveTrackedSources(workspaceDir, pages, registry, onProgress)
296
390
  * and honoring it is what repairs an existing workspace in one pull instead of
297
391
  * by hand. Every other verb keeps refusing an old manifest outright — the site
298
392
  * binding is checked before this runs, so a foreign manifest never reaches it.
393
+ *
394
+ * What it does *not* honor is a comparison recorded under the superseded
395
+ * scheme; see `withoutSupersededRemoteHashes`.
299
396
  */
300
397
  async function readSourceRegistry(workspaceDir, siteId) {
301
398
  if (!await workspaceFileExists(workspaceDir, MANIFEST_FILE_NAME)) return new Map();
@@ -308,7 +405,64 @@ async function readSourceRegistry(workspaceDir, siteId) {
308
405
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.siteId !== siteId) {
309
406
  return new Map();
310
407
  }
311
- return pageSourceRegistry(parsed);
408
+ const registry = pageSourceRegistry(parsed);
409
+ return parsed.manifestVersion === MANIFEST_VERSION ? registry : withoutSupersededRemoteHashes(registry);
410
+ }
411
+
412
+ /**
413
+ * Forgets `baseline.remoteHash` on every entry of a registry read out of a
414
+ * manifest that is not this version's.
415
+ *
416
+ * The hash answers "did the site's projected body change", which is the
417
+ * question version 6 stopped asking: the projection moves on its own, and the
418
+ * SHY home page's clean deploy is what proved it. On the first pull after the
419
+ * upgrade there is no recorded revision yet, so the comparison would fall back
420
+ * to that hash — and for a Markdown page, which is always kept and therefore
421
+ * always conflicts on a remote change, a projection that had moved in the
422
+ * meantime would refuse the pull *and* write no manifest. The documented
423
+ * recovery, `pages push`, reads the manifest strictly and refuses a superseded
424
+ * `manifestVersion` telling the operator to pull again, so the two verbs would
425
+ * send each other in a circle with no way out but deleting the source.
426
+ *
427
+ * Dropping the hash makes the migration pull establish the revision baseline
428
+ * instead, which is what every later pull compares. `sourceHash` is kept: it
429
+ * answers a question that has not changed — whether the author has edited a
430
+ * pulled ProseMirror source since — and losing it would let this same pull
431
+ * refresh the file over unpushed work.
432
+ *
433
+ * This discards state; it does not migrate any. An older manifest is still
434
+ * fully replaced by the pull that reads it.
435
+ */
436
+ function withoutSupersededRemoteHashes(registry) {
437
+ for (const record of registry.values()) {
438
+ const baseline = record.baseline;
439
+ if (baseline?.remoteHash === undefined) continue;
440
+ const kept = {
441
+ ...(baseline.sourceHash === undefined ? {} : { sourceHash: baseline.sourceHash }),
442
+ ...(baseline.revision === undefined ? {} : { revision: baseline.revision }),
443
+ };
444
+ record.baseline = Object.keys(kept).length === 0 ? undefined : kept;
445
+ }
446
+ return registry;
447
+ }
448
+
449
+ /**
450
+ * The page read is the same response that supplies the body and the
451
+ * revision, so its title and path are the ones that revision describes. The
452
+ * listing's are one round trip older: a rename that landed in between would
453
+ * otherwise be recorded beside the newer revision, and the next push would
454
+ * send the old title or path against a revision that matches — reverting the
455
+ * rename with no conflict to report (TR00643).
456
+ */
457
+ function titleFromRead(page, summary) {
458
+ // Stripped the way the listing's title is (`roundTrippedText` in api.js):
459
+ // control and bidi characters are terminal and diff safety, not fidelity.
460
+ return typeof page.title === "string" ? sanitizeDiagnostic(page.title, "") : summary.title;
461
+ }
462
+
463
+ function pathFromRead(page, summary) {
464
+ const normalized = typeof page.path === "string" ? normalizePagePath(page.path) : undefined;
465
+ return normalized === undefined ? summary.path : normalized;
312
466
  }
313
467
 
314
468
  function freeFormBody(page) {
@@ -388,8 +542,8 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
388
542
  PAGES_DIRECTORY,
389
543
  );
390
544
  }
391
- // These two do carry a site, so they can clear themselves.
392
- for (const artifact of [NAVIGATION_FILE_NAME, MEDIA_MANIFEST_FILE_NAME]) {
545
+ // These three do carry a site, so they can clear themselves.
546
+ for (const artifact of [NAVIGATION_FILE_NAME, MEDIA_MANIFEST_FILE_NAME, REDIRECTS_FILE_NAME]) {
393
547
  const artifactBinding = await inspectArtifactSiteBinding(workspaceDir, artifact);
394
548
  if (artifactBinding.state === "absent") continue;
395
549
  if (artifactBinding.state === "bound" && artifactBinding.siteId === siteId) continue;
@@ -420,6 +574,31 @@ export async function pull(invocation) {
420
574
  const tracked = await resolveTrackedSources(config.workspaceDir, freeForm, registry, onProgress);
421
575
  const pageFiles = assignPageFiles(freeForm, tracked);
422
576
 
577
+ // The redirect map, with the revision a later push is fenced by (TR00702).
578
+ // Read here, before the first workspace write, for the same reason the
579
+ // settings groups below are: a failure must strand nothing. A Taproot that
580
+ // predates the map answers the route with 404; that pull then records no
581
+ // redirect baseline, and `redirects push` asks for a pull against a site
582
+ // that has one rather than guessing.
583
+ onProgress("Reading the redirect map.");
584
+ let redirectMap;
585
+ try {
586
+ redirectMap = await getSiteRedirectMap(client, siteId);
587
+ } catch (error) {
588
+ if (
589
+ error instanceof ApiError
590
+ && error.refusalKind() === REFUSAL_UNCLASSIFIED
591
+ && error.httpStatus === 404
592
+ ) {
593
+ onProgress(
594
+ `This Taproot does not serve a redirect map yet; ${REDIRECTS_FILE_NAME} is not written and the manifest `
595
+ + "records no redirect baseline.",
596
+ );
597
+ } else {
598
+ throw error;
599
+ }
600
+ }
601
+
423
602
  // Project every settings response before the first workspace write. Theme
424
603
  // decoding is a wire-contract check and can fail on malformed stored data;
425
604
  // discovering that after page bodies were written would strand an
@@ -485,7 +664,13 @@ export async function pull(invocation) {
485
664
  // page's one source. Dropping it from the registry would let the next
486
665
  // pull mint the competing document all over again.
487
666
  onProgress(`Page '${summary.path}' has no readable free-form body; keeping '${source.file}' as its source.`);
488
- trackedPlans.set(summary.pageId, { description, file: source.file, sourceFormat: source.sourceFormat });
667
+ trackedPlans.set(summary.pageId, {
668
+ description,
669
+ title: titleFromRead(page, summary),
670
+ path: pathFromRead(page, summary),
671
+ file: source.file,
672
+ sourceFormat: source.sourceFormat,
673
+ });
489
674
  continue;
490
675
  }
491
676
  const baselineFile = internalPageBaselineFile(summary.pageId);
@@ -513,7 +698,29 @@ export async function pull(invocation) {
513
698
  const remoteHash = canonicalDocumentHash(body);
514
699
  const sourceBytes = await readWorkspaceFile(config.workspaceDir, source.file, WORKSPACE_LIMITS.documentBytes);
515
700
  const sourceHash = workspaceContentHash(sourceBytes);
516
- const remoteChanged = source.baseline?.remoteHash !== undefined && source.baseline.remoteHash !== remoteHash;
701
+ const revision = normalizePageBodyRevision(page.bodyRevision);
702
+ // The revision is the site's own statement about its stored state, so it
703
+ // moves for a real edit to the body, title, path, or description and for
704
+ // nothing else. The body hash cannot make that distinction: every read
705
+ // re-projects image delivery and re-serializes component data, so a
706
+ // signer rotation or a changed image reads as an edit — which is exactly
707
+ // what refused the SHY home page's pull after a clean deploy. So the
708
+ // hash is consulted only where there is no revision to compare: a
709
+ // Taproot that predates the revision contract, where it is still strictly
710
+ // better than assuming nothing moved.
711
+ //
712
+ // A site that *now* reports a revision establishes the revision baseline
713
+ // instead of comparing a superseded hash, even when the manifest recorded
714
+ // one. That manifest is this version's — `withoutSupersededRemoteHashes`
715
+ // never touched it — but its hash was recorded against a Taproot that had
716
+ // no revision to give, so it is the same superseded projected-body hash,
717
+ // and honoring it would refuse forever every Markdown page whose image
718
+ // projection moved across the API deploy. This costs one pull's detection
719
+ // at the upgrade boundary, the same trade `withoutSupersededRemoteHashes`
720
+ // accepts.
721
+ const remoteChanged = revision !== undefined
722
+ ? source.baseline?.revision !== undefined && source.baseline.revision !== revision
723
+ : source.baseline?.remoteHash !== undefined && source.baseline.remoteHash !== remoteHash;
517
724
  // Markdown is one-way, so a Markdown source can never be rewritten from
518
725
  // the site's document and is always kept. A ProseMirror source can be
519
726
  // refreshed — but only over content the author has not edited since the
@@ -542,6 +749,8 @@ export async function pull(invocation) {
542
749
  const conflicted = markdown ? remoteChanged : localChanged && remoteChanged;
543
750
  trackedPlans.set(summary.pageId, {
544
751
  description,
752
+ title: titleFromRead(page, summary),
753
+ path: pathFromRead(page, summary),
545
754
  file: source.file,
546
755
  sourceFormat: source.sourceFormat,
547
756
  // `sourceHash` records the source as of the last time it *agreed* with
@@ -553,17 +762,80 @@ export async function pull(invocation) {
553
762
  // carries its previous hash forward until a push records a new one or
554
763
  // a remote change turns the divergence into a conflict.
555
764
  baseline: keepLocal
556
- ? { remoteHash, sourceHash: localChanged ? source.baseline?.sourceHash : sourceHash }
557
- : { remoteHash, sourceHash: workspaceContentHash(serialized) },
765
+ // The revision records what this workspace has reconciled with, and
766
+ // a conflicted page reconciles with nothing: this manifest is never
767
+ // written, because the refusal below is raised before it is. That is
768
+ // what keeps the same conflict coming back until somebody resolves
769
+ // it, rather than the site's new version being adopted by a pull
770
+ // that refused to adopt it.
771
+ ? {
772
+ remoteHash,
773
+ sourceHash: localChanged ? source.baseline?.sourceHash : sourceHash,
774
+ ...(revision === undefined ? {} : { revision }),
775
+ }
776
+ : {
777
+ remoteHash,
778
+ sourceHash: workspaceContentHash(serialized),
779
+ ...(revision === undefined ? {} : { revision }),
780
+ },
558
781
  ...(keepLocal ? { baselineBody: serialized, baselineFile } : { refresh: serialized, baselineFile }),
559
782
  });
560
- if (conflicted) {
561
- conflicts.push({
562
- pagePath: normalizePagePath(summary.path) ?? summary.path,
563
- file: source.file,
564
- baselineFile,
565
- localChanged,
566
- });
783
+ if (remoteChanged) {
784
+ // A refusal for this same version already worked this out, against a
785
+ // baseline it then overwrote with the very version being compared.
786
+ // Recomputing now would compare that version with itself, find nothing,
787
+ // and report "the change is in the page's title, path, or description"
788
+ // for what was a body edit. So a record naming exactly this version is
789
+ // authoritative about what the operator was last shown — including its
790
+ // having been unable to tell — and a page that has moved *again* names
791
+ // a different one and is compared afresh, against the version that
792
+ // refusal preserved.
793
+ //
794
+ // "This version" is the revision where the site reports one, and the
795
+ // canonical body hash where it does not. Against a Taproot that
796
+ // predates the revision contract — which the published CLI meets
797
+ // whenever it runs ahead of the deployed API — the hash is the whole of
798
+ // what the fallback comparison above is made against, so it is equally
799
+ // the thing a record has to name.
800
+ const observed = await readObservedPageRecord(config.workspaceDir, summary.pageId);
801
+ const alreadyShown = observed !== undefined
802
+ && (revision === undefined
803
+ ? observed.revision === undefined && observed.remoteHash === remoteHash
804
+ : observed.revision === revision);
805
+ // Computed here, while both documents are in hand and before the
806
+ // preserved baseline below is overwritten with the new one — after
807
+ // that write the previous version is gone and nothing could say what
808
+ // moved.
809
+ const differences = alreadyShown
810
+ ? observed.differences
811
+ : await describeRemoteMovement({
812
+ workspaceDir: config.workspaceDir,
813
+ baselineFile,
814
+ sourceBytes,
815
+ markdown,
816
+ localChanged,
817
+ body,
818
+ });
819
+ // Sanitized as one message: the page path is server data and the paths
820
+ // are drawn from an untrusted document, and the same strings reach
821
+ // `error.differences` de-fanged. A progress line is the one place they
822
+ // would otherwise reach a terminal raw.
823
+ onProgress(
824
+ sanitizeDiagnostic(`Page '${summary.path}' moved on the site. ${describeDifferenceSummary(differences)}`),
825
+ );
826
+ if (conflicted) {
827
+ conflicts.push({
828
+ pageId: summary.pageId,
829
+ pagePath: normalizePagePath(summary.path) ?? summary.path,
830
+ file: source.file,
831
+ baselineFile,
832
+ localChanged,
833
+ differences,
834
+ observedRevision: revision,
835
+ observedRemoteHash: remoteHash,
836
+ observedRevisionFile: internalPageObservedRevisionFile(summary.pageId),
837
+ });
838
+ }
567
839
  } else if (keepLocal && localChanged) {
568
840
  // Two different situations, and saying "kept your edits" for both
569
841
  // would be a guess dressed as a fact: with a recorded hash the
@@ -587,7 +859,35 @@ export async function pull(invocation) {
587
859
  if (plan.baselineBody === undefined) continue;
588
860
  await writeWorkspaceFile(config.workspaceDir, plan.baselineFile, plan.baselineBody);
589
861
  }
590
- if (conflicts.length > 0) throw pullConflict(conflicts);
862
+ if (conflicts.length > 0) {
863
+ // Recorded beside the preserved body, and only for a page this pull is
864
+ // about to refuse. It says "the operator has been shown this version",
865
+ // which is what keeps the refusal's own recovery — push the local source
866
+ // to make the site match it — reachable without weakening the push
867
+ // refusal for a page that moved while nobody was looking.
868
+ //
869
+ // The differences ride along because the write above has just replaced
870
+ // the only copy they could be computed from. Without them the second
871
+ // refusal for an unchanged version would carry no `differences` at all
872
+ // and claim the change was not in the body.
873
+ //
874
+ // Written for every conflict, not only for a site that reports a
875
+ // revision. A site that does not is compared by body hash, and skipping
876
+ // the record there left the second refusal recomputing against the copy
877
+ // the first one overwrote — finding no difference, and reporting a body
878
+ // edit as a metadata change every time.
879
+ for (const conflict of conflicts) {
880
+ if (conflict.observedRevisionFile === undefined) continue;
881
+ await writeWorkspaceJson(config.workspaceDir, conflict.observedRevisionFile, {
882
+ pageId: conflict.pageId,
883
+ ...(conflict.observedRevision === undefined
884
+ ? { remoteHash: conflict.observedRemoteHash }
885
+ : { revision: conflict.observedRevision }),
886
+ ...(conflict.differences === undefined ? {} : { differences: conflict.differences }),
887
+ });
888
+ }
889
+ throw pullConflict(conflicts);
890
+ }
591
891
 
592
892
  // Phase two: everything else, plus the tracked refreshes cleared above.
593
893
  const manifestPages = [];
@@ -605,9 +905,19 @@ export async function pull(invocation) {
605
905
  workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
606
906
  };
607
907
  if (summary.templateType === TEMPLATE_TYPE_FREE_FORM) {
908
+ // This pull is reconciling the page, so the manifest below carries the
909
+ // newer and stronger claim and the "what the operator was shown" record
910
+ // is spent. Leaving it would let a push override a remote change this
911
+ // workspace has since caught up with.
912
+ const observedRevisionFile = internalPageObservedRevisionFile(summary.pageId);
913
+ if (observedRevisionFile !== undefined) {
914
+ await deleteWorkspaceFile(config.workspaceDir, observedRevisionFile);
915
+ }
608
916
  const plan = trackedPlans.get(summary.pageId);
609
917
  if (plan !== undefined) {
610
918
  entry.description = plan.description;
919
+ if (plan.title !== undefined) entry.title = plan.title;
920
+ if (plan.path !== undefined) entry.path = plan.path;
611
921
  if (plan.file !== undefined) {
612
922
  entry.file = plan.file;
613
923
  entry.sourceFormat = plan.sourceFormat;
@@ -631,6 +941,8 @@ export async function pull(invocation) {
631
941
  const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
632
942
  const body = freeFormBody(page);
633
943
  entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
944
+ entry.title = titleFromRead(page, summary);
945
+ entry.path = pathFromRead(page, summary);
634
946
  if (body === undefined) {
635
947
  // The server accepts and stores a body it never validates, so an
636
948
  // unreadable one is a real state. It is reported rather than written
@@ -649,9 +961,11 @@ export async function pull(invocation) {
649
961
  } else {
650
962
  entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
651
963
  entry.sourceFormat = pageSourceFormat(entry.file);
964
+ const revision = normalizePageBodyRevision(page.bodyRevision);
652
965
  entry.baseline = {
653
966
  remoteHash: canonicalDocumentHash(body),
654
967
  sourceHash: workspaceContentHash(source),
968
+ ...(revision === undefined ? {} : { revision }),
655
969
  };
656
970
  }
657
971
  await writeWorkspaceFile(config.workspaceDir, entry.file, source);
@@ -667,6 +981,14 @@ export async function pull(invocation) {
667
981
  const navItems = await getNavigation(client, siteId);
668
982
  await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, { siteId, navItems });
669
983
 
984
+ if (redirectMap !== undefined) {
985
+ await writeWorkspaceJson(
986
+ config.workspaceDir,
987
+ REDIRECTS_FILE_NAME,
988
+ projectRedirectMapForWorkspace(siteId, redirectMap),
989
+ );
990
+ }
991
+
670
992
  for (const document of settingsDocuments) {
671
993
  await writeWorkspaceJson(config.workspaceDir, document.file, document.value);
672
994
  }
@@ -677,6 +999,7 @@ export async function pull(invocation) {
677
999
  pulledAt: new Date(now()).toISOString(),
678
1000
  pagesTruncated: truncated,
679
1001
  navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
1002
+ ...(redirectMap === undefined ? {} : { redirects: redirectsManifestEntry(redirectMap) }),
680
1003
  settings: pulledSettings,
681
1004
  settingsSkipped: skippedSettings,
682
1005
  ...(pulledFooter === undefined ? {} : { footer: footerManifestEntry(pulledFooter) }),
@@ -720,6 +1043,15 @@ export async function pull(invocation) {
720
1043
  ...(reported.truncated ? { itemsTruncated: true } : {}),
721
1044
  },
722
1045
  navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
1046
+ // Absent, not zeroed, when the site served no map: an agent reading
1047
+ // `entries: 0` would take the site for one with no redirects.
1048
+ ...(redirectMap === undefined ? {} : {
1049
+ redirects: {
1050
+ file: REDIRECTS_FILE_NAME,
1051
+ revision: redirectMap.revision,
1052
+ entries: redirectMap.entries.length,
1053
+ },
1054
+ }),
723
1055
  settings: {
724
1056
  pulled: pulledSettings.map((entry) => entry.settingsType),
725
1057
  skipped: skippedSettings,
@@ -0,0 +1,90 @@
1
+ import { getSiteRedirectMap, withRefusalGuidance } from "../api.js";
2
+ import { REFUSAL_UNCLASSIFIED, VERB_REDIRECTS_PULL } from "../constants.js";
3
+ import { SiteAuthoringError } from "../errors.js";
4
+ import {
5
+ projectRedirectMapForWorkspace,
6
+ REDIRECT_KIND_GONE,
7
+ REDIRECT_ORIGIN_AUTHORED,
8
+ REDIRECTS_FILE_NAME,
9
+ } from "../redirects-contract.js";
10
+ import { redirectsManifestEntry } from "../redirects-workspace.js";
11
+ import { boundedList, openSession, successResult } from "../session.js";
12
+ import { ApiError } from "../transport.js";
13
+ import { readManifest, writeManifest, writeWorkspaceJson } from "../workspace.js";
14
+
15
+ const MAXIMUM_REPORTED = 200;
16
+
17
+ /**
18
+ * `redirects pull` — snapshot the site's whole redirect map.
19
+ *
20
+ * It writes `redirects.json` and records the map's revision in the pull
21
+ * manifest, because a whole-map push has to be fenced against the state it was
22
+ * built from: the map holds entries nobody typed — one per page or tag rename —
23
+ * and a stale replace would delete them.
24
+ *
25
+ * It is a verb of its own as well as part of `pull` so an agent can re-read
26
+ * just the map after a rename, or after a conflicting push, without re-pulling
27
+ * every page.
28
+ */
29
+ export async function redirectsPull(invocation) {
30
+ const { client, config, siteId, onProgress } = await openSession(invocation);
31
+ // The manifest is required rather than optional: without one there is no
32
+ // pulled workspace for this file to belong to, and the same refusal `pages
33
+ // push` gives is the right one here.
34
+ const manifest = await readManifest(config.workspaceDir, siteId);
35
+
36
+ const map = await withRefusalGuidance(
37
+ onProgress,
38
+ "redirects pull",
39
+ async () => await readRedirectMapOrRefuse(client, siteId),
40
+ );
41
+
42
+ const document_ = projectRedirectMapForWorkspace(siteId, map);
43
+ await writeWorkspaceJson(config.workspaceDir, REDIRECTS_FILE_NAME, document_);
44
+ manifest.redirects = redirectsManifestEntry(map);
45
+ await writeManifest(config.workspaceDir, manifest);
46
+ onProgress(
47
+ `Wrote ${REDIRECTS_FILE_NAME} with ${map.entries.length} entr${map.entries.length === 1 ? "y" : "ies"} `
48
+ + "and recorded the map revision.",
49
+ );
50
+
51
+ const authored = map.entries.filter((entry) => entry.origin === REDIRECT_ORIGIN_AUTHORED).length;
52
+ const reported = boundedList(document_.entries, MAXIMUM_REPORTED);
53
+ return successResult(VERB_REDIRECTS_PULL, siteId, {
54
+ redirectsFile: REDIRECTS_FILE_NAME,
55
+ revision: map.revision,
56
+ redirects: {
57
+ total: map.entries.length,
58
+ // Split so an agent can tell what it declared from what a rename
59
+ // recorded, which is the difference between "mine to edit" and "the
60
+ // site's own history, delete it only on purpose".
61
+ authored,
62
+ pathHistory: map.entries.length - authored,
63
+ gone: map.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
64
+ items: reported.items,
65
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * A Taproot that predates the redirect map answers the route with a bare 404.
72
+ * Named here rather than surfaced as an unclassified status, because the
73
+ * refusal `redirects push` gives for a missing baseline sends the operator to
74
+ * this verb (TR00702).
75
+ */
76
+ async function readRedirectMapOrRefuse(client, siteId) {
77
+ try {
78
+ return await getSiteRedirectMap(client, siteId);
79
+ } catch (error) {
80
+ if (error instanceof ApiError && error.refusalKind() === REFUSAL_UNCLASSIFIED && error.httpStatus === 404) {
81
+ throw new SiteAuthoringError(
82
+ "redirects.not_served",
83
+ "This Taproot does not serve a redirect map yet. Wait for the deploy that adds it, then run "
84
+ + "'taproot-site redirects pull' again.",
85
+ { status: error.status },
86
+ );
87
+ }
88
+ throw error;
89
+ }
90
+ }