canopycms 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +112 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/branch-schema-cache.d.ts +22 -2
  20. package/dist/branch-schema-cache.js +88 -12
  21. package/dist/cli/cli.js +91 -18
  22. package/dist/cli/generate-ai-content.js +72 -11
  23. package/dist/cli/init.js +2 -2
  24. package/dist/content-reader.d.ts +24 -10
  25. package/dist/content-reader.js +43 -6
  26. package/dist/context.js +43 -41
  27. package/dist/editor/BranchManager.d.ts +8 -4
  28. package/dist/editor/BranchManager.js +43 -18
  29. package/dist/editor/Editor.js +3 -2
  30. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  31. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  32. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  33. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  34. package/dist/editor/admin/useSystemHealth.js +21 -0
  35. package/dist/editor/branch-lock.d.ts +2 -0
  36. package/dist/editor/branch-lock.js +5 -3
  37. package/dist/editor/branch-status.d.ts +25 -1
  38. package/dist/editor/branch-status.js +75 -0
  39. package/dist/editor/components/EditorHeader.d.ts +6 -2
  40. package/dist/editor/components/EditorHeader.js +51 -30
  41. package/dist/editor/components/EntryHeader.d.ts +3 -1
  42. package/dist/editor/components/EntryHeader.js +2 -1
  43. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  44. package/dist/editor/components/TooltipDetail.js +18 -0
  45. package/dist/editor/copy.d.ts +7 -0
  46. package/dist/editor/copy.js +9 -0
  47. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  48. package/dist/editor/hooks/useBranchManager.js +48 -5
  49. package/dist/pr-comments.d.ts +2 -0
  50. package/dist/pr-comments.js +20 -14
  51. package/dist/resource-generation.js +2 -3
  52. package/dist/server.d.ts +6 -1
  53. package/dist/server.js +6 -1
  54. package/dist/submission-attribution.d.ts +1 -1
  55. package/dist/submission-attribution.js +20 -2
  56. package/dist/task-queue/task-queue.d.ts +5 -1
  57. package/dist/task-queue/task-queue.js +4 -2
  58. package/dist/types.d.ts +61 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/worker/branch-merge.d.ts +68 -0
  62. package/dist/worker/branch-merge.js +228 -0
  63. package/dist/worker/canopy-state.d.ts +2 -0
  64. package/dist/worker/canopy-state.js +1 -1
  65. package/dist/worker/cms-worker.d.ts +1 -0
  66. package/dist/worker/cms-worker.js +5 -3
  67. package/dist/worker/git-sync.js +15 -8
  68. package/dist/worker/github-commits.d.ts +106 -0
  69. package/dist/worker/github-commits.js +485 -0
  70. package/dist/worker/github-gateway-http.js +3 -2
  71. package/dist/worker/github-gateway-wire.d.ts +24 -15
  72. package/dist/worker/github-gateway-wire.js +4 -1
  73. package/dist/worker/github-gateway.d.ts +16 -5
  74. package/dist/worker/github-gateway.js +22 -5
  75. package/dist/worker/github-mirror.d.ts +8 -0
  76. package/dist/worker/github-mirror.js +12 -1
  77. package/dist/worker/github-transfer.d.ts +3 -1
  78. package/dist/worker/github-transfer.js +3 -2
  79. package/dist/worker/history-rewrite.d.ts +13 -1
  80. package/dist/worker/history-rewrite.js +27 -3
  81. package/dist/worker/rebase.d.ts +13 -7
  82. package/dist/worker/rebase.js +351 -95
  83. package/dist/worker/task-runner.d.ts +1 -1
  84. package/dist/worker/task-runner.js +172 -34
  85. package/dist/worker/worker-context.d.ts +2 -0
  86. package/package.json +1 -1
@@ -8,7 +8,7 @@ import { syncPrComments } from './pr-comment-sync.js';
8
8
  import { CommentStore } from '../comment-store.js';
9
9
  import { getErrorMessage } from '../utils/error.js';
10
10
  import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../authorization/review-role.js';
11
- import { getBranchProtection } from '../authorization/index.js';
11
+ import { getBranchProtection, isAdmin } from '../authorization/index.js';
12
12
  import { sanitizeDisplayName } from '../submission-attribution.js';
13
13
  /** Optional on the wire: a bodiless request means no note. */
14
14
  const requestChangesBodySchema = z
@@ -130,8 +130,11 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
130
130
  };
131
131
  }
132
132
  const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
133
- // As for request changes, the status on disk decides, in the step that writes.
133
+ // As for request changes, the status on disk decides, in the step that writes. So does an
134
+ // unfinished submit: what's approved may not be what the pull request holds.
134
135
  let found;
136
+ let unfinished = false;
137
+ let notQueued = false;
135
138
  const updated = await meta.saveIf({
136
139
  branch: {
137
140
  name: branchContext.branch.name,
@@ -140,8 +143,22 @@ const approveBranchHandler = async (gc, ctx, req, _params) => {
140
143
  },
141
144
  }, (existing) => {
142
145
  found = existing?.branch.status;
143
- return found === 'submitted';
146
+ unfinished = existing?.branch.submitUnfinished === true;
147
+ notQueued = existing?.branch.submitNotQueued === true;
148
+ return found === 'submitted' && !unfinished;
144
149
  });
150
+ if (unfinished && found === 'submitted') {
151
+ return {
152
+ ok: false,
153
+ status: 409,
154
+ error: "This branch's last submit didn't finish, so it can't be approved until it does. " +
155
+ (notQueued
156
+ ? 'Use "Request changes" to send it back to its editor.'
157
+ : isAdmin(req.user.groups)
158
+ ? 'Retry the submit from the "Tasks" tab in "System health".'
159
+ : 'Ask an admin to retry the submit.'),
160
+ };
161
+ }
145
162
  if (!updated)
146
163
  return approveConflict(found);
147
164
  // Against the branch the save returned, as for request changes. Best-effort: syncPrComments
@@ -6,7 +6,7 @@ import { withdrawBranch } from './branch-withdraw.js';
6
6
  import { requestChanges, approveBranch } from './branch-review.js';
7
7
  import { markAsMerged } from './branch-merge.js';
8
8
  import { defineEndpoint } from './route-builder.js';
9
- import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
9
+ import { canPerformWorkflowAction, getBranchProtection, isAdmin } from '../authorization/index.js';
10
10
  import { syncSubmitPr } from './github-sync.js';
11
11
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
12
12
  import { isNonFastForwardRejection } from '../utils/git.js';
@@ -85,6 +85,19 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
85
85
  : `Cannot submit branch with status '${status}'. Only 'editing' branches can be submitted.`,
86
86
  };
87
87
  }
88
+ // The worker pushes nothing for such a branch, so this would commit and report a failure later.
89
+ if (branchContext.branch.rewrittenOnGitHub !== undefined) {
90
+ return {
91
+ ok: false,
92
+ status: 409,
93
+ error: `"${shortNameOf(ctx.services.config, branchContext.branch.name)}" was changed on GitHub in ` +
94
+ "a way CanopyCMS can't combine with the work here, so it can't be submitted. Nothing is " +
95
+ 'lost. ' +
96
+ (isAdmin(req.user.groups)
97
+ ? 'See the "Branches" tab in "System health".'
98
+ : 'Ask an admin for help.'),
99
+ };
100
+ }
88
101
  // Commit and push changes
89
102
  const submitter = submissionEditorFromUser(req.user);
90
103
  const { authPlugin } = ctx;
@@ -122,28 +135,21 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
122
135
  // (e.g. x-access-token:TOKEN@github.com in git errors) are redacted even
123
136
  // from server logs — a live installation token must never persist in logs.
124
137
  console.error(`CanopyCMS: Failed to push branch changes (${branchContext.branchRoot}):`, redactCredentials(message));
125
- // A non-fast-forward rejection means this branch and the deployment's
126
- // local repository have diverged. Retrying the identical push can never
127
- // succeed (see isNonFastForwardRejection), so surface 409 instead of the
128
- // generic 500 below. Everything else (network, auth) keeps the 500 path.
129
- //
130
- // This push targets the deployment's OWN local origin (remote.git), not
131
- // GitHub, so the message deliberately states only the observable fact and
132
- // names no cause: a foreign deployment cannot reach this repo, and the
133
- // realistic explanations are all internal (the worker's rebase loop
134
- // reconciling the branch, or a commit reaching remote.git that this
135
- // workspace never had). It also never advises renaming the branch --
136
- // a branch that reaches this point has usually been submitted before, so
137
- // it may well have an open PR that a rename would orphan.
138
+ // This push targets the deployment's OWN remote.git, so a non-fast-forward
139
+ // rejection means it holds a commit this clone lacks, which reached it from
140
+ // GitHub (worker/github-commits.ts). The commit made above stays: the tree is
141
+ // then clean, which is what the worker's merge needs, and the next submit
142
+ // pushes the result. A branch that reaches this point has usually been
143
+ // submitted before, so the message never advises renaming it: that would
144
+ // orphan its open PR.
138
145
  if (isNonFastForwardRejection(message)) {
139
146
  return {
140
147
  ok: false,
141
148
  status: 409,
142
- error: `Could not submit "${shortName}": it has diverged from the copy in ` +
143
- `this deployment's repository and needs to be reconciled before it can be submitted. ` +
144
- `The background worker reconciles branches when the base branch moves, so this often ` +
145
- `clears on its own within a few minutes -- try again shortly, and ask an administrator ` +
146
- `to check the worker if it persists.`,
149
+ error: `Could not submit "${shortName}": it has commits from GitHub that aren't in the editor ` +
150
+ `yet. Your saved changes are safe. CanopyCMS brings those commits in at its next sync, ` +
151
+ `usually within a few minutes, so submit again then. If this keeps happening, ` +
152
+ `${isAdmin(req.user.groups) ? 'check' : 'ask an admin to check'} "System health".`,
147
153
  };
148
154
  }
149
155
  return {
@@ -200,6 +206,11 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params, body) => {
200
206
  // warning through review and archive.
201
207
  rebaseFailure: undefined,
202
208
  review: undefined,
209
+ // Read by the card as soon as the status says review, and the worker's next read is minutes
210
+ // away, so what held the last submit must not show as holding this one.
211
+ pullRequestHold: undefined,
212
+ submitUnfinished: prResult.submitNotQueued,
213
+ submitNotQueued: prResult.submitNotQueued,
203
214
  },
204
215
  });
205
216
  return {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { BranchContext, BranchMetadata } from '../types.js';
2
+ import type { BranchContext, BranchHold, BranchMetadata } from '../types.js';
3
3
  import type { ApiContext, ApiRequest, ApiResponse } from './types.js';
4
4
  import { branchParamSchema } from './validators.js';
5
5
  import type { UserProfile } from '../auth/types.js';
@@ -15,7 +15,11 @@ import type { UserProfile } from '../auth/types.js';
15
15
  * rather than silently behaving as if nothing changed. That is by design:
16
16
  * wire compatibility here means "doesn't break", not "behaves the same".
17
17
  */
18
- export interface BranchListItem extends Omit<BranchMetadata, 'review'> {
18
+ export interface BranchListItem extends Omit<BranchMetadata, 'review' | 'keptOverGitHub' | 'lastGitHubTip' | 'rewrittenOnGitHub' | 'submitUnfinished' | 'unpublishedAtMerge' | 'submitNotQueued' | 'rebaseFailure' | 'pullRequestHold'> {
19
+ /** What is stuck and needs a person, for the branch card. Names no file. */
20
+ hold?: BranchHold;
21
+ /** The last submit didn't finish, which the server refuses Approve for. */
22
+ approveBlocked?: boolean;
19
23
  /**
20
24
  * What pull request links carry for this branch, which `?branch=` and the `/:branch` segment
21
25
  * accept beside `name` (branch-param.ts's `shortNameOf`): `name` less the namespace for a branch
@@ -32,7 +36,8 @@ export interface BranchListItem extends Omit<BranchMetadata, 'review'> {
32
36
  */
33
37
  writeBlocked?: boolean;
34
38
  /**
35
- * Populated from {@link BranchWriteProtection.submitBlockedIncludingStatus}
39
+ * Populated from {@link BranchWriteProtection.submitBlockedIncludingStatus},
40
+ * or true for a branch rewritten on GitHub
36
41
  * -- READ THAT DOC COMMENT before touching this field. On the wire this
37
42
  * name means the COMPOUND answer (base-branch OR non-'editing' status),
38
43
  * mirroring how `writeBlocked` above is the compound of `readOnly` + status
@@ -126,16 +126,36 @@ const accessForViewer = (access, viewer) => {
126
126
  */
127
127
  export const toBranchListItem = (config, branch, viewer) => {
128
128
  const protection = getBranchWriteProtection(config, branch.name, branch.baseBranch, branch.status);
129
- // `review` names the reviewer, whom the editor has no use for.
130
- const { review: _review, ...listed } = branch;
129
+ // `review` names the reviewer, whom the editor has no use for. `keptOverGitHub` and
130
+ // `rebaseFailure` can name files, which a viewer's path rules can hide; System Health reads
131
+ // failures from the admin endpoint. The others are the worker's.
132
+ const { review: _review, keptOverGitHub: _kept, lastGitHubTip: _tip, rewrittenOnGitHub: _rewritten, submitUnfinished: _unfinished, submitNotQueued, unpublishedAtMerge: _unpublished, rebaseFailure: _failure, pullRequestHold, ...listed } = branch;
133
+ const inReview = branch.status === 'submitted' || branch.status === 'approved';
134
+ const rewritten = branch.rewrittenOnGitHub !== undefined && branch.status !== 'archived';
135
+ const hold = rewritten
136
+ ? { kind: 'rewritten' }
137
+ : !inReview
138
+ ? branch.unpublishedAtMerge
139
+ ? { kind: 'unpublished-at-merge' }
140
+ : undefined
141
+ : branch.submitUnfinished
142
+ ? {
143
+ kind: 'submit-unfinished',
144
+ opened: branch.pullRequestNumber !== undefined,
145
+ notQueued: submitNotQueued,
146
+ }
147
+ : pullRequestHold;
131
148
  return {
132
149
  ...listed,
150
+ hold,
151
+ approveBlocked: (inReview && branch.submitUnfinished) || undefined,
133
152
  shortName: shortNameOf(config, branch.name),
134
153
  ...accessForViewer(branch.access ?? {}, viewer),
135
154
  isProtected: protection.isProtected,
136
155
  readOnly: protection.readOnly,
137
156
  writeBlocked: protection.writeBlocked,
138
- submitBlocked: protection.submitBlockedIncludingStatus,
157
+ // The worker refuses to push such a branch, so a submit could only fail later.
158
+ submitBlocked: protection.submitBlockedIncludingStatus || rewritten,
139
159
  };
140
160
  };
141
161
  /**
@@ -572,6 +592,15 @@ export const deleteBranchHandler = async (ctx, req, params) => {
572
592
  error: 'Cannot delete branch with open pull request',
573
593
  };
574
594
  }
595
+ // It may hold the only copy of commits the merge left out, and deleting it would drop the alert
596
+ // that says so.
597
+ if (branchContext.branch.unpublishedAtMerge && !isAdmin(req.user.groups)) {
598
+ return {
599
+ ok: false,
600
+ status: 409,
601
+ error: 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.',
602
+ };
603
+ }
575
604
  // The branch directory leaves its name by one rename, under the same
576
605
  // server-enforced lockfile branch-metadata saves hold (see
577
606
  // utils/occ-json-write.ts), so a save() that reaches that lock after the
@@ -23,7 +23,7 @@ import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGrou
23
23
  import type { UserInfoResponse } from './user.js';
24
24
  import type { AddEntryTypeApiResponse, CreateCollectionApiResponse, DeleteCollectionApiResponse, GetCollectionApiResponse, GetSchemaApiResponse, InvalidateSchemaCacheApiResponse, RemoveEntryTypeApiResponse, UpdateCollectionApiResponse, UpdateEntryTypeApiResponse, UpdateOrderApiResponse, UpdateOrderBody } from './schema.js';
25
25
  import type { CreateCollectionInput, CreateEntryTypeInput, UpdateCollectionInput, UpdateEntryTypeInput } from './../schema/schema-store-types.js';
26
- import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
26
+ import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
27
27
  /** Options for {@link createApiClient}. */
28
28
  export interface ApiClientOptions {
29
29
  /** Base URL for API requests. Defaults to '/api/canopycms'. */
@@ -206,6 +206,8 @@ export declare class CanopyApiClient {
206
206
  purgeBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<PurgeBranchDirResponse>;
207
207
  /** POST /admin/branch-dirs/:dirName/repair-metadata */
208
208
  repairBranchDir: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairBranchDirResponse>;
209
+ /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
210
+ dismissUnpublished: (params: Record<string, string>, options?: RequestOptions) => Promise<DismissUnpublishedResponse>;
209
211
  /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
210
212
  repairContentDuplicates: (params: Record<string, string>, options?: RequestOptions) => Promise<RepairContentDuplicatesResponse>;
211
213
  /** GET /admin/status */
@@ -259,6 +259,10 @@ export class CanopyApiClient {
259
259
  repairBranchDir: (params, options) => {
260
260
  return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-metadata', params), undefined, 'long', options);
261
261
  },
262
+ /** POST /admin/branch-dirs/:dirName/dismiss-unpublished */
263
+ dismissUnpublished: (params, options) => {
264
+ return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/dismiss-unpublished', params), undefined, 'standard', options);
265
+ },
262
266
  /** POST /admin/branch-dirs/:dirName/repair-content-duplicates */
263
267
  repairContentDuplicates: (params, options) => {
264
268
  return this.request('POST', this.buildPath('/admin/branch-dirs/:dirName/repair-content-duplicates', params), undefined, 'long', options);
@@ -14,6 +14,8 @@ export interface GitHubSyncResult {
14
14
  nothingToSubmit?: boolean;
15
15
  /** The direct path, whose submit pushes to GitHub itself: the branch is there whatever the PR call did. */
16
16
  pushedToGitHub?: boolean;
17
+ /** No task runs this submit, so only submitting again finishes it. */
18
+ submitNotQueued?: boolean;
17
19
  }
18
20
  /** What the PR body records about a submit. */
19
21
  export interface SubmissionRecord {
@@ -10,6 +10,8 @@ import { shortNameOf } from '../branch-param.js';
10
10
  import { CommentStore } from '../comment-store.js';
11
11
  import { prBodyLockKey, refreshCommentsBlock } from '../pr-comments.js';
12
12
  import { withLock } from '../utils/async-mutex.js';
13
+ /** A submit's commits reached this deployment's repository, and nothing will take them further. */
14
+ const SUBMIT_NOT_QUEUED_REASON = "This submit couldn't be queued, so nothing reached GitHub. Nothing is lost.";
13
15
  /** The branch's comment threads for the PR body, or null (said so in the body) if unreadable. */
14
16
  async function readThreadsOrWarn(context) {
15
17
  try {
@@ -153,7 +155,7 @@ export async function syncSubmitPr(ctx, context, submission) {
153
155
  // recorded the number, the next submit would otherwise re-enqueue
154
156
  // 'push-and-create-pr' and 422 on GitHub's duplicate-PR check, wedging the
155
157
  // branch in 'sync-failed' with no way to recover.
156
- return enqueueGitHubTask(ctx, context, {
158
+ const queued = await enqueueGitHubTask(ctx, context, {
157
159
  action: 'push-and-create-or-update-pr',
158
160
  payload: {
159
161
  branch: context.branch.name,
@@ -171,6 +173,9 @@ export async function syncSubmitPr(ctx, context, submission) {
171
173
  submittedAt: submission.submittedAt,
172
174
  },
173
175
  });
176
+ return queued.syncStatus === 'sync-failed'
177
+ ? { ...queued, syncFailureReason: SUBMIT_NOT_QUEUED_REASON, submitNotQueued: true }
178
+ : queued;
174
179
  }
175
180
  /**
176
181
  * Used by withdraw and request-changes. Uses githubService directly if
@@ -66,7 +66,9 @@ export async function syncPrComments(ctx, context) {
66
66
  const pending = await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER);
67
67
  const queued = pending.some((t) => t.action === SYNC_PR_COMMENTS_ACTION &&
68
68
  t.payload.branch === branch &&
69
- t.payload.pullRequestNumber === prNumber);
69
+ t.payload.pullRequestNumber === prNumber &&
70
+ // The worker's own task leaves the comment summary as it is, so it carries no comment.
71
+ t.payload.reviewBlockOnly !== true);
70
72
  if (queued)
71
73
  return;
72
74
  await enqueueTask(taskDir, {
@@ -82,9 +82,31 @@ export declare const branchMetadataSchema: z.ZodObject<{
82
82
  lastAt: string;
83
83
  }>>;
84
84
  historyRewrittenFrom: z.ZodOptional<z.ZodString>;
85
+ lastGitHubTip: z.ZodOptional<z.ZodString>;
86
+ keptOverGitHub: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
87
+ rewrittenOnGitHub: z.ZodOptional<z.ZodString>;
88
+ submitUnfinished: z.ZodOptional<z.ZodBoolean>;
89
+ submitNotQueued: z.ZodOptional<z.ZodBoolean>;
90
+ unpublishedAtMerge: z.ZodOptional<z.ZodBoolean>;
85
91
  syncFailureReason: z.ZodOptional<z.ZodString>;
86
92
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
87
93
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
94
+ pullRequestHold: z.ZodCatch<z.ZodOptional<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
95
+ kind: z.ZodLiteral<"conflicts">;
96
+ }, "strip", z.ZodTypeAny, {
97
+ kind: "conflicts";
98
+ }, {
99
+ kind: "conflicts";
100
+ }>, z.ZodObject<{
101
+ kind: z.ZodLiteral<"retargeted">;
102
+ base: z.ZodString;
103
+ }, "strip", z.ZodTypeAny, {
104
+ kind: "retargeted";
105
+ base: string;
106
+ }, {
107
+ kind: "retargeted";
108
+ base: string;
109
+ }>]>>>;
88
110
  review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
89
111
  state: z.ZodEnum<["approved", "changes-requested"]>;
90
112
  by: z.ZodObject<{
@@ -175,9 +197,31 @@ export declare const branchMetadataSchema: z.ZodObject<{
175
197
  lastAt: string;
176
198
  }>>;
177
199
  historyRewrittenFrom: z.ZodOptional<z.ZodString>;
200
+ lastGitHubTip: z.ZodOptional<z.ZodString>;
201
+ keptOverGitHub: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
202
+ rewrittenOnGitHub: z.ZodOptional<z.ZodString>;
203
+ submitUnfinished: z.ZodOptional<z.ZodBoolean>;
204
+ submitNotQueued: z.ZodOptional<z.ZodBoolean>;
205
+ unpublishedAtMerge: z.ZodOptional<z.ZodBoolean>;
178
206
  syncFailureReason: z.ZodOptional<z.ZodString>;
179
207
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
180
208
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
209
+ pullRequestHold: z.ZodCatch<z.ZodOptional<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
210
+ kind: z.ZodLiteral<"conflicts">;
211
+ }, "strip", z.ZodTypeAny, {
212
+ kind: "conflicts";
213
+ }, {
214
+ kind: "conflicts";
215
+ }>, z.ZodObject<{
216
+ kind: z.ZodLiteral<"retargeted">;
217
+ base: z.ZodString;
218
+ }, "strip", z.ZodTypeAny, {
219
+ kind: "retargeted";
220
+ base: string;
221
+ }, {
222
+ kind: "retargeted";
223
+ base: string;
224
+ }>]>>>;
181
225
  review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
182
226
  state: z.ZodEnum<["approved", "changes-requested"]>;
183
227
  by: z.ZodObject<{
@@ -268,9 +312,31 @@ export declare const branchMetadataSchema: z.ZodObject<{
268
312
  lastAt: string;
269
313
  }>>;
270
314
  historyRewrittenFrom: z.ZodOptional<z.ZodString>;
315
+ lastGitHubTip: z.ZodOptional<z.ZodString>;
316
+ keptOverGitHub: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
317
+ rewrittenOnGitHub: z.ZodOptional<z.ZodString>;
318
+ submitUnfinished: z.ZodOptional<z.ZodBoolean>;
319
+ submitNotQueued: z.ZodOptional<z.ZodBoolean>;
320
+ unpublishedAtMerge: z.ZodOptional<z.ZodBoolean>;
271
321
  syncFailureReason: z.ZodOptional<z.ZodString>;
272
322
  editors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
273
323
  uncommittedEditors: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
324
+ pullRequestHold: z.ZodCatch<z.ZodOptional<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
325
+ kind: z.ZodLiteral<"conflicts">;
326
+ }, "strip", z.ZodTypeAny, {
327
+ kind: "conflicts";
328
+ }, {
329
+ kind: "conflicts";
330
+ }>, z.ZodObject<{
331
+ kind: z.ZodLiteral<"retargeted">;
332
+ base: z.ZodString;
333
+ }, "strip", z.ZodTypeAny, {
334
+ kind: "retargeted";
335
+ base: string;
336
+ }, {
337
+ kind: "retargeted";
338
+ base: string;
339
+ }>]>>>;
274
340
  review: z.ZodCatch<z.ZodOptional<z.ZodObject<{
275
341
  state: z.ZodEnum<["approved", "changes-requested"]>;
276
342
  by: z.ZodObject<{
@@ -81,9 +81,23 @@ const branchMetadataShape = {
81
81
  .object({ message: z.string(), firstAt: z.string(), lastAt: z.string() })
82
82
  .optional(),
83
83
  historyRewrittenFrom: z.string().optional(),
84
+ lastGitHubTip: z.string().optional(),
85
+ keptOverGitHub: z.array(z.string()).optional(),
86
+ rewrittenOnGitHub: z.string().optional(),
87
+ submitUnfinished: z.boolean().optional(),
88
+ submitNotQueued: z.boolean().optional(),
89
+ unpublishedAtMerge: z.boolean().optional(),
84
90
  syncFailureReason: z.string().optional(),
85
91
  editors: z.array(z.string()).optional(),
86
92
  uncommittedEditors: z.array(z.string()).optional(),
93
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
94
+ pullRequestHold: z
95
+ .discriminatedUnion('kind', [
96
+ z.object({ kind: z.literal('conflicts') }),
97
+ z.object({ kind: z.literal('retargeted'), base: z.string() }),
98
+ ])
99
+ .optional()
100
+ .catch(undefined),
87
101
  // Dropped when malformed: it only decorates the PR body, so it never fails a read.
88
102
  review: z
89
103
  .object({
@@ -119,8 +119,10 @@ export declare function buildInitialBranchMetadata(incoming: BranchMetadataUpdat
119
119
  export interface BranchMetadataUpdate {
120
120
  branch?: Partial<Omit<BranchMetadata, 'createdAt' | 'updatedAt'>>;
121
121
  }
122
+ /** A failed submit whose pull request step hasn't run; no other task's success clears its failure. */
123
+ export declare const awaitsSubmitRetry: ({ submitUnfinished, status }: BranchMetadata) => boolean;
122
124
  /**
123
- * The metadata update for archiving a branch whose PR merged. Shared by the
125
+ * The metadata update for archiving `branch`, whose PR merged. Shared by the
124
126
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged
125
127
  * API (api/branch-merge.ts), so both produce identical metadata.
126
128
  *
@@ -128,7 +130,7 @@ export interface BranchMetadataUpdate {
128
130
  * existing values for fields the incoming update omits, so the branch's ref and
129
131
  * its recorded PR number and URL survive untouched.
130
132
  */
131
- export declare function buildMergedBranchUpdate(now?: Date): NonNullable<BranchMetadataUpdate['branch']>;
133
+ export declare function buildMergedBranchUpdate(branch: BranchMetadata, now?: Date): NonNullable<BranchMetadataUpdate['branch']>;
132
134
  /** {@link BranchMetadataFileManager.get} as a function. */
133
135
  export declare const getBranchMetadataFileManager: (branchRoot: string, baseRoot: string, options?: {
134
136
  settleMs?: number;
@@ -267,8 +267,14 @@ function mergeBranchMetadata(existing, version, incoming, now = new Date().toISO
267
267
  export function buildInitialBranchMetadata(incoming, now) {
268
268
  return mergeBranchMetadata(null, null, incoming, now);
269
269
  }
270
+ /** A failed submit whose pull request step hasn't run; no other task's success clears its failure. */
271
+ export const awaitsSubmitRetry = ({ submitUnfinished, status }) => submitUnfinished === true && (status === 'submitted' || status === 'approved');
272
+ /** Only a submit writes `pending-sync` to branch.json, so that too is a submit not yet finished. */
273
+ const owesSubmit = (branch) => awaitsSubmitRetry(branch) ||
274
+ (branch.syncStatus === 'pending-sync' &&
275
+ (branch.status === 'submitted' || branch.status === 'approved'));
270
276
  /**
271
- * The metadata update for archiving a branch whose PR merged. Shared by the
277
+ * The metadata update for archiving `branch`, whose PR merged. Shared by the
272
278
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged
273
279
  * API (api/branch-merge.ts), so both produce identical metadata.
274
280
  *
@@ -276,7 +282,7 @@ export function buildInitialBranchMetadata(incoming, now) {
276
282
  * existing values for fields the incoming update omits, so the branch's ref and
277
283
  * its recorded PR number and URL survive untouched.
278
284
  */
279
- export function buildMergedBranchUpdate(now = new Date()) {
285
+ export function buildMergedBranchUpdate(branch, now = new Date()) {
280
286
  return {
281
287
  status: 'archived',
282
288
  pullRequestState: 'merged',
@@ -284,6 +290,8 @@ export function buildMergedBranchUpdate(now = new Date()) {
284
290
  // The merge settled every conflict the record named.
285
291
  conflictStatus: 'clean',
286
292
  conflictFiles: [],
293
+ submitUnfinished: undefined,
294
+ ...(owesSubmit(branch) ? { unpublishedAtMerge: true } : {}),
287
295
  };
288
296
  }
289
297
  /** {@link BranchMetadataFileManager.get} as a function. */
@@ -33,8 +33,15 @@ export interface ResolvedBranchSchema {
33
33
  issues: SchemaIssue[];
34
34
  }
35
35
  /**
36
- * Per-branch schema cache: one file in {@link schemaCacheDir} with no in-memory
37
- * layer, so it stays coherent across Lambda invocations.
36
+ * Per-branch schema cache: one file in {@link schemaCacheDir}, the only state
37
+ * shared across processes. Each process keeps the entries it parsed or wrote,
38
+ * keyed by the file's identity, so it parses an unchanged file once while the
39
+ * entry stays in its size-bounded memo. Every call still opens the file and
40
+ * runs every freshness check below on the entry, and the file is only ever
41
+ * replaced by rename (see {@link fileIdentity}), so a process is as fresh as if
42
+ * it re-read the file. The identity is fstat'ed on an open handle because NFS
43
+ * revalidates attributes on open (close-to-open), while a bare stat() may
44
+ * answer from its attribute cache.
38
45
  *
39
46
  * Freshness follows the generation-marker protocol owned by
40
47
  * resource-generation.ts, and this is one of that protocol's durable-snapshot
@@ -58,6 +65,16 @@ export interface ResolvedBranchSchema {
58
65
  export declare class BranchSchemaCache {
59
66
  /** Tracks when we last checked mtimes per contentRoot, to debounce rapid requests */
60
67
  private lastMtimeCheck;
68
+ /** Parsed cache files, by absolute path; see the class doc. */
69
+ private readonly snapshots;
70
+ /**
71
+ * In-flight resolves of the checkout during a build, by content root and registry. A build
72
+ * reads a checkout that does not change under it, so those two are its whole input and the
73
+ * result needs no revalidation. Only {@link isBuildMode}: `CANOPY_BUILD_MODE` is a build and
74
+ * script switch, and a long-lived process setting it would see a schema edit only on
75
+ * restart. A rejected resolve is dropped, so a strict-mode failure fails every read.
76
+ */
77
+ private readonly buildResolves;
61
78
  private readonly devMode;
62
79
  constructor(mode?: OperatingMode);
63
80
  /**
@@ -79,6 +96,9 @@ export declare class BranchSchemaCache {
79
96
  */
80
97
  protected resolveFresh(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry, unknownSchema: UnknownSchemaPolicy): Promise<SchemaResolutionResult>;
81
98
  private loadFromCacheOrResolve;
99
+ private resolveForBuild;
100
+ /** The parsed cache file, or null when it is absent or unreadable. */
101
+ private readSnapshot;
82
102
  /**
83
103
  * Resolve the schema fresh from disk and persist it (subject to the
84
104
  * skip-persist rule below), UNCONDITIONALLY -- never through the cache-read