canopycms 0.0.67 → 0.0.68-int.93

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 (107) hide show
  1. package/dist/ai/handler.js +8 -0
  2. package/dist/api/admin-branch-health.js +12 -7
  3. package/dist/api/admin.d.ts +8 -8
  4. package/dist/api/branch-status.js +6 -2
  5. package/dist/api/client.d.ts +12 -0
  6. package/dist/api/client.js +35 -11
  7. package/dist/api/content.d.ts +6 -5
  8. package/dist/api/content.js +38 -39
  9. package/dist/api/entries.js +6 -8
  10. package/dist/api/github-sync.d.ts +10 -1
  11. package/dist/api/github-sync.js +20 -10
  12. package/dist/api/reference-options.js +1 -1
  13. package/dist/api/request-url.d.ts +8 -0
  14. package/dist/api/request-url.js +15 -0
  15. package/dist/api/resolve-references.js +2 -2
  16. package/dist/api/settings-helpers.d.ts +1 -1
  17. package/dist/api/settings-helpers.js +1 -4
  18. package/dist/authorization/content.d.ts +5 -4
  19. package/dist/authorization/content.js +5 -5
  20. package/dist/authorization/path.d.ts +11 -6
  21. package/dist/authorization/path.js +11 -6
  22. package/dist/authorization/types.d.ts +2 -2
  23. package/dist/branch-health.js +3 -3
  24. package/dist/branch-schema-cache.d.ts +5 -3
  25. package/dist/branch-schema-cache.js +29 -11
  26. package/dist/branch-workspace.js +2 -2
  27. package/dist/cli/cli.js +285 -168
  28. package/dist/cli/generate-ai-content.js +190 -109
  29. package/dist/cli/github-app-manifest.d.ts +4 -3
  30. package/dist/cli/github-app-manifest.js +4 -3
  31. package/dist/cli/init.js +15 -10
  32. package/dist/cli/sync.js +15 -13
  33. package/dist/config/schemas/config.d.ts +6 -3
  34. package/dist/config/schemas/config.js +3 -1
  35. package/dist/config/schemas/field.js +1 -0
  36. package/dist/config/types.d.ts +11 -2
  37. package/dist/config/validation.d.ts +6 -0
  38. package/dist/config/validation.js +37 -0
  39. package/dist/content-listing.d.ts +2 -2
  40. package/dist/content-listing.js +3 -3
  41. package/dist/content-reader.js +4 -4
  42. package/dist/content-store.d.ts +2 -0
  43. package/dist/content-store.js +2 -1
  44. package/dist/content-tree.js +1 -1
  45. package/dist/context.d.ts +16 -7
  46. package/dist/context.js +116 -67
  47. package/dist/editor/Editor.js +4 -4
  48. package/dist/editor/FormRenderer.js +20 -5
  49. package/dist/editor/admin/SystemHealthPanel.js +37 -6
  50. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  51. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  52. package/dist/editor/hooks/useBranchesData.js +3 -1
  53. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  54. package/dist/editor/hooks/useDraftManager.js +2 -1
  55. package/dist/editor/hooks/useEntriesData.js +2 -2
  56. package/dist/editor/hooks/useEntryManager.js +17 -11
  57. package/dist/entry-schema-registry.js +2 -1
  58. package/dist/git-manager.d.ts +20 -0
  59. package/dist/git-manager.js +50 -4
  60. package/dist/github-service.d.ts +8 -0
  61. package/dist/github-service.js +5 -1
  62. package/dist/http/handler.js +24 -9
  63. package/dist/http/index.d.ts +2 -0
  64. package/dist/http/index.js +2 -0
  65. package/dist/http/router.d.ts +2 -0
  66. package/dist/http/router.js +2 -1
  67. package/dist/http/worker-not-ready.d.ts +9 -0
  68. package/dist/http/worker-not-ready.js +17 -0
  69. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/paths/index.d.ts +1 -1
  72. package/dist/paths/index.js +1 -1
  73. package/dist/paths/normalize.d.ts +7 -0
  74. package/dist/paths/normalize.js +9 -0
  75. package/dist/reference-resolver.d.ts +3 -3
  76. package/dist/reference-resolver.js +3 -2
  77. package/dist/resolve-canopy-user.js +2 -1
  78. package/dist/services.d.ts +12 -5
  79. package/dist/services.js +56 -56
  80. package/dist/settings-workspace.js +36 -0
  81. package/dist/static/seo.d.ts +2 -14
  82. package/dist/static/seo.js +2 -25
  83. package/dist/submission-attribution.d.ts +73 -0
  84. package/dist/submission-attribution.js +221 -0
  85. package/dist/sync-core.js +7 -8
  86. package/dist/types.d.ts +23 -2
  87. package/dist/utils/content-write-lock.d.ts +3 -7
  88. package/dist/utils/content-write-lock.js +6 -4
  89. package/dist/utils/debug.d.ts +8 -0
  90. package/dist/utils/debug.js +10 -2
  91. package/dist/utils/git.d.ts +28 -0
  92. package/dist/utils/git.js +38 -0
  93. package/dist/utils/provisioning-lock.d.ts +15 -5
  94. package/dist/utils/provisioning-lock.js +31 -11
  95. package/dist/utils/request-timing.d.ts +25 -0
  96. package/dist/utils/request-timing.js +101 -0
  97. package/dist/utils/url-prefix.d.ts +15 -0
  98. package/dist/utils/url-prefix.js +26 -0
  99. package/dist/worker/canopy-state.d.ts +45 -0
  100. package/dist/worker/canopy-state.js +75 -0
  101. package/dist/worker/git-sync.d.ts +4 -2
  102. package/dist/worker/git-sync.js +111 -24
  103. package/dist/worker/provisioned-workspace.d.ts +35 -0
  104. package/dist/worker/provisioned-workspace.js +50 -0
  105. package/dist/worker/rebase.js +75 -12
  106. package/dist/worker/task-runner.js +39 -4
  107. package/package.json +1 -1
package/dist/context.js CHANGED
@@ -1,9 +1,9 @@
1
- import { isDeployedStatic, isBuildMode, STATIC_DEPLOY_USER } from './build-mode.js';
1
+ import { isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER } from './build-mode.js';
2
2
  import { createContentReader } from './content-reader.js';
3
3
  import { ContentStoreError } from './content-store.js';
4
- import { createLogicalPath, parseSlug, resolveBranchPaths } from './paths/index.js';
4
+ import { BranchPathError, createLogicalPath, parseSlug, resolveBranchPaths, } from './paths/index.js';
5
5
  import { resolveUrlPathCandidates } from './url-path-resolver.js';
6
- import { loadOrCreateBranchContext } from './branch-workspace.js';
6
+ import { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace.js';
7
7
  import { buildContentTree as buildContentTreeImpl, } from './content-tree.js';
8
8
  import { listEntries as listEntriesImpl, } from './content-listing.js';
9
9
  import { createDebugLogger } from './utils/debug.js';
@@ -22,6 +22,21 @@ function isLookupFailure(err) {
22
22
  function isPageSwallowable(err) {
23
23
  return isLookupFailure(err) || err.code === 'FORBIDDEN';
24
24
  }
25
+ /**
26
+ * A branch that already has a workspace, or null. A name the path layer
27
+ * rejects (a traversal segment) is null too: it arrives from the request, so it
28
+ * lists nothing rather than failing the page.
29
+ */
30
+ async function loadExistingBranch(branchName, mode) {
31
+ try {
32
+ return await loadBranchContext({ branchName, mode });
33
+ }
34
+ catch (err) {
35
+ if (err instanceof BranchPathError)
36
+ return null;
37
+ throw err;
38
+ }
39
+ }
25
40
  /**
26
41
  * Create a Canopy context managing auth + content reading. Framework-agnostic:
27
42
  * the adapter supplies extractUser.
@@ -119,80 +134,114 @@ export function createCanopyContext(options) {
119
134
  }
120
135
  return null;
121
136
  };
122
- /** Resolve branch workspace and schema — shared by buildContentTree and listEntries. Memoized per getContext call. */
123
- let schemaContextPromise = null;
124
- const resolveSchemaContextImpl = async () => {
125
- const operatingMode = services.config.mode;
126
- const defaultBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
127
- const branchContext = await loadOrCreateBranchContext({
128
- config: services.config,
129
- branchName: defaultBranch,
130
- mode: operatingMode,
131
- createdBy: 'canopycms-context',
132
- remoteUrl: services.config.defaultRemoteUrl,
133
- });
134
- const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
135
- const contentRootName = services.config.contentRoot || 'content';
136
- const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
137
- return { branchContext, branchRoot, flatSchema, contentRootName };
138
- };
139
- const resolveSchemaContext = () => {
140
- if (!schemaContextPromise) {
141
- schemaContextPromise = resolveSchemaContextImpl();
142
- }
143
- return schemaContextPromise;
144
- };
145
137
  /**
146
- * Path-ACL predicate for the batch reads (listEntries / buildContentTree),
147
- * memoized per getContext call like the schema context above. Without it an
148
- * unfiltered listing on this request-scoped, ACL-enforcing context would
149
- * disclose full entry `data` for paths the user cannot `read()` directly.
150
- *
151
- * `services.createContentAccessChecker` is the shared batch primitive
152
- * (api/entries.ts uses it too): it resolves the request-constant work —
153
- * branch access, the settings/permissions root, the rule set — once, and
154
- * returns a synchronous per-path check, so the per-entry cost is an admin
155
- * short-circuit or one minimatch per configured rule, with no extra I/O.
138
+ * What a batch read (listEntries / buildContentTree) lists from: the branch's
139
+ * schema plus its path-ACL predicate, or null when the branch yields nothing
140
+ * this user may see.
156
141
  *
157
- * The empty object (no predicate → unfiltered) at build time, on static
158
- * deployments, and for the synthetic admin is load-bearing, not an
159
- * optimization. That user has unconditional access anyway (path checks
160
- * bypass entirely for an Admins-group user — authorization/path.ts), but
161
- * BUILDING the checker costs a getSettingsBranchRoot() call, which in modes
162
- * with a separate settings branch provisions that branch's git workspace: an
163
- * EFS round trip in prod. `createBuildCanopy` (build-canopy.ts) runs outside
164
- * a request or Next.js build phase, so neither other guard fires for it, and
165
- * without this one every such script pays for a settings-workspace clone it
166
- * never needed — and hard-fails where that workspace cannot be provisioned.
142
+ * With no `branch`, or the active branch named explicitly, it is the active
143
+ * branch, provisioned if missing. Any other name arrives from the request
144
+ * (an index page passing the preview iframe's `?branch=`), so it is
145
+ * load-only, never provisioned — a missing, non-string or traversal name
146
+ * lists nothing — and its branch access, held in its own metadata, is
147
+ * checked before its schema or content files are read. At
148
+ * build time and on static deployments `branch` selects nothing, as for
149
+ * `read` (see the module doc).
167
150
  *
168
- * Compared by reference to the STATIC_DEPLOY_USER singleton, not by group
169
- * membership, so it stays scoped to the synthetic build identity: a real
170
- * authenticated admin at request time still goes through the real check.
171
- *
172
- * Deliberately NOT wrapped in try/catch — createContentAccessChecker is
173
- * fail-loud by contract, and swallowing would serve an unfiltered listing.
151
+ * Memoized per getContext call and per branch name, on the in-flight
152
+ * promise; a null result is memoized too, so one request answers a given
153
+ * name consistently. The memo is request-scoped, so it carries no
154
+ * generation protocol (docs/concurrency.md, "Adding a call-scoped memo").
174
155
  */
175
- let visibilityPromise = null;
176
- const resolveVisibilityImpl = async () => {
177
- if (isDeployedStatic(services.config) || isBuildMode() || user === STATIC_DEPLOY_USER)
178
- return {};
179
- const { branchContext, branchRoot } = await resolveSchemaContext();
180
- const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
181
- return { shouldInclude: (physicalPath) => checkAccess(physicalPath, 'read').allowed };
156
+ const listingSources = new Map();
157
+ // Read once, so every listing in this request agrees on it even if a
158
+ // concurrent request's refreshActiveBranch() replaces services.config.
159
+ const activeBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
160
+ const resolveListingSource = (branch) => {
161
+ // Typed `string`, but usually handed over from untyped `searchParams`,
162
+ // where a repeated `?branch=` arrives as an array.
163
+ if (branch !== undefined && typeof branch !== 'string')
164
+ return Promise.resolve(null);
165
+ const requested = branch && branch !== activeBranch && !readsFromCheckout(services.config)
166
+ ? branch
167
+ : undefined;
168
+ const key = requested ?? activeBranch;
169
+ let source = listingSources.get(key);
170
+ if (!source) {
171
+ source = resolveListingSourceImpl(requested);
172
+ listingSources.set(key, source);
173
+ }
174
+ return source;
182
175
  };
183
- const resolveVisibility = () => {
184
- if (!visibilityPromise) {
185
- visibilityPromise = resolveVisibilityImpl();
176
+ const resolveListingSourceImpl = async (requested) => {
177
+ const operatingMode = services.config.mode;
178
+ const branchContext = requested === undefined
179
+ ? await loadOrCreateBranchContext({
180
+ config: services.config,
181
+ branchName: activeBranch,
182
+ mode: operatingMode,
183
+ createdBy: 'canopycms-context',
184
+ remoteUrl: services.config.defaultRemoteUrl,
185
+ })
186
+ : await loadExistingBranch(requested, operatingMode);
187
+ if (!branchContext)
188
+ return null;
189
+ // The path-ACL predicate keeps an unfiltered listing on this ACL-enforcing
190
+ // context from disclosing entry `data` for paths the user cannot `read()`.
191
+ // `services.createContentAccessChecker` is the shared batch primitive
192
+ // (api/entries.ts uses it too): it resolves the request-constant work —
193
+ // branch access, the settings/permissions root, the rule set — once, and
194
+ // returns a synchronous per-path check, so the per-entry cost is an admin
195
+ // short-circuit or one minimatch per configured rule, with no extra I/O.
196
+ //
197
+ // No predicate (unfiltered) at build time, on static deployments, and for
198
+ // the synthetic admin is load-bearing, not an optimization. That user has
199
+ // unconditional access anyway (path checks bypass entirely for an
200
+ // Admins-group user — authorization/path.ts), but BUILDING the checker costs
201
+ // a getSettingsBranchRoot() call, which in modes with a separate settings
202
+ // branch provisions that branch's git workspace: an EFS round trip in prod.
203
+ // `createBuildCanopy` (build-canopy.ts) runs outside a request or Next.js
204
+ // build phase, so neither other guard fires for it, and without this one
205
+ // every such script pays for a settings-workspace clone it never needed —
206
+ // and hard-fails where that workspace cannot be provisioned. Compared by
207
+ // reference to the STATIC_DEPLOY_USER singleton, not by group membership,
208
+ // so a real authenticated admin at request time still goes through the
209
+ // real check.
210
+ //
211
+ // Deliberately NOT wrapped in try/catch — createContentAccessChecker is
212
+ // fail-loud by contract, and swallowing would serve an unfiltered listing.
213
+ const enforceAcls = !(isDeployedStatic(services.config) ||
214
+ isBuildMode() ||
215
+ user === STATIC_DEPLOY_USER);
216
+ if (enforceAcls && !services.checkBranchAccess(branchContext, user).allowed) {
217
+ return null;
186
218
  }
187
- return visibilityPromise;
219
+ const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
220
+ const contentRootName = services.config.contentRoot || 'content';
221
+ const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
222
+ if (!enforceAcls)
223
+ return { branchRoot, flatSchema, contentRootName, visibility: {} };
224
+ const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
225
+ return {
226
+ branchRoot,
227
+ flatSchema,
228
+ contentRootName,
229
+ visibility: { shouldInclude: (logicalPath) => checkAccess(logicalPath, 'read').allowed },
230
+ };
188
231
  };
189
232
  const buildContentTree = async (options) => {
190
- const { branchRoot, flatSchema, contentRootName } = await resolveSchemaContext();
191
- return buildContentTreeImpl(branchRoot, flatSchema, contentRootName, options, await resolveVisibility());
233
+ const { branch, ...treeOptions } = options ?? {};
234
+ const source = await resolveListingSource(branch);
235
+ if (!source)
236
+ return [];
237
+ return buildContentTreeImpl(source.branchRoot, source.flatSchema, source.contentRootName, treeOptions, source.visibility);
192
238
  };
193
239
  const listEntries = async (options) => {
194
- const { branchRoot, flatSchema, contentRootName } = await resolveSchemaContext();
195
- return listEntriesImpl(branchRoot, flatSchema, contentRootName, options, await resolveVisibility());
240
+ const { branch, ...listOptions } = options ?? {};
241
+ const source = await resolveListingSource(branch);
242
+ if (!source)
243
+ return [];
244
+ return listEntriesImpl(source.branchRoot, source.flatSchema, source.contentRootName, listOptions, source.visibility);
196
245
  };
197
246
  return {
198
247
  read,
@@ -24,6 +24,7 @@ import { EditorFooter, EditorHeader, EditorSidebar } from './components/index.js
24
24
  import { RenameEntryModal } from './components/RenameEntryModal.js';
25
25
  import { EntryCreateModal } from './components/EntryCreateModal.js';
26
26
  import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
27
+ import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
27
28
  import { CollectionEditor } from './schema-editor/index.js';
28
29
  import { AssetContextProvider, useApiClient } from './context/index.js';
29
30
  import { EntryLinkContext } from './fields/entry-link/index.js';
@@ -224,9 +225,8 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
224
225
  // branch's content. Writing it would (a) show it as the new branch's
225
226
  // content and (b) satisfy the skip gate above, permanently suppressing
226
227
  // the new branch's own load -- which also leaves the new branch's OCC
227
- // token unset, turning the next save into a blind overwrite that can
228
- // never 409. Drop it instead; the new branch has its own load running
229
- // under its own key.
228
+ // token unset, so saveEntry refuses every save of the entry. Drop it
229
+ // instead; the new branch has its own load running under its own key.
230
230
  if (currentBranchRef.current !== requestBranch)
231
231
  return;
232
232
  setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
@@ -644,7 +644,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
644
644
  ? 'Setting up your branch workspace…'
645
645
  : entriesInitializing
646
646
  ? 'Loading content…'
647
- : 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(CenteredMessage, { children: "You don't have permission to edit this content." })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
647
+ : 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryPath: currentEntry.path })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
648
648
  const contentId = currentEntry?.contentId;
649
649
  if (contentId) {
650
650
  setDrafts((prev) => ({ ...prev, [contentId]: next }));
@@ -20,6 +20,18 @@ import { formatCanopyPath, normalizeCanopyPath } from './canopy-path.js';
20
20
  import { FieldWrapper } from './comments/FieldWrapper.js';
21
21
  import { EntryComments } from './comments/EntryComments.js';
22
22
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
23
+ /**
24
+ * Card heading for an object-list item: the value of its `itemTitleField` when that is a
25
+ * non-blank string or a number, otherwise `fallback`.
26
+ */
27
+ const listItemTitle = (item, itemTitleField, fallback) => {
28
+ const raw = itemTitleField ? item?.[itemTitleField] : undefined;
29
+ if (typeof raw === 'number' && Number.isFinite(raw))
30
+ return String(raw);
31
+ if (typeof raw === 'string' && raw.trim() !== '')
32
+ return raw.trim();
33
+ return fallback;
34
+ };
23
35
  const normalizeOptions = (options) => {
24
36
  if (!options)
25
37
  return [];
@@ -135,11 +147,14 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
135
147
  const items = Array.isArray(currentValue)
136
148
  ? currentValue
137
149
  : [];
138
- return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => (_jsx(Paper, { withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { size: "xs", fw: 700, children: [label, " #", idx + 1] }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { label: objectField.label, fields: objectField.fields, value: item, onChange: (next) => {
139
- const nextItems = [...items];
140
- nextItems[idx] = next;
141
- update(nextItems);
142
- }, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])))), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
150
+ return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
151
+ const itemTitle = listItemTitle(item, objectField.itemTitleField, `${label} #${idx + 1}`);
152
+ return (_jsx(Paper, { role: "group", "aria-label": itemTitle, withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, children: itemTitle }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { fields: objectField.fields, value: item, onChange: (next) => {
153
+ const nextItems = [...items];
154
+ nextItems[idx] = next;
155
+ update(nextItems);
156
+ }, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])));
157
+ }), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
143
158
  }
144
159
  // Unlike the list branch above (each item has its own Remove button), a
145
160
  // non-list object has no other way back to `undefined`: a required child
@@ -59,7 +59,9 @@ function purgeGateFor(entry) {
59
59
  }
60
60
  return {
61
61
  disabled: lockFresh,
62
- tooltip: lockFresh ? 'Provisioning may be in progress' : undefined,
62
+ tooltip: lockFresh
63
+ ? 'Provisioning, or the worker syncing this branch, may be in progress'
64
+ : undefined,
63
65
  };
64
66
  }
65
67
  function workerLivenessBadge(worker, mode) {
@@ -76,6 +78,33 @@ function workerLivenessBadge(worker, mode) {
76
78
  return { color: 'red', label: 'Worker: absent' };
77
79
  }
78
80
  }
81
+ const BASE_REFRESH_LABELS = {
82
+ refreshed: 'fast-forwarded',
83
+ 'up-to-date': 'up to date',
84
+ 'skipped-dirty': 'refresh skipped (uncommitted changes)',
85
+ 'skipped-locked': 'refresh skipped (workspace busy: provisioning or an admin action)',
86
+ 'skipped-not-provisioned': 'not yet provisioned',
87
+ failed: 'refresh failed',
88
+ };
89
+ /**
90
+ * Why the base branch needs an operator, or null when its last refresh needs
91
+ * nothing. Shared by the overview and the base row's warning tooltip.
92
+ */
93
+ function baseRefreshWarning(report) {
94
+ if (!report)
95
+ return null;
96
+ const lines = [];
97
+ if (report.outcome === 'skipped-dirty' || report.outcome === 'failed') {
98
+ lines.push(`Base branch ${BASE_REFRESH_LABELS[report.outcome]}${report.message ? `: ${report.message}` : ''}`);
99
+ if (report.dirtyFiles?.length)
100
+ lines.push(`Uncommitted: ${report.dirtyFiles.join(', ')}`);
101
+ }
102
+ if (report.trackedCanopyMeta?.length) {
103
+ lines.push(`The site repo tracks canopycms state (${report.trackedCanopyMeta.join(', ')}). ` +
104
+ 'Untrack it with `git rm -r --cached .canopy-meta`, add `.canopy-meta/` to .gitignore, and commit.');
105
+ }
106
+ return lines.length > 0 ? lines.join('\n') : null;
107
+ }
79
108
  // Mirrors BranchManager.tsx's statusColorMap -- kept local (not exported
80
109
  // there) rather than shared, same tiny lookup either way.
81
110
  const branchStatusColorMap = {
@@ -121,9 +150,10 @@ function OverviewTab({ health }) {
121
150
  const liveness = workerLivenessBadge(status.worker, status.mode);
122
151
  const lastFatalError = status.workerStatus?.lastFatalError;
123
152
  const lastGitSync = status.workerStatus?.lastGitSync;
153
+ const baseWarning = baseRefreshWarning(lastGitSync?.baseRefresh);
124
154
  return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
125
- ? ` · ${lastGitSync.skippedLocked.length} skipped (content write in progress)`
126
- : ''] }), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
155
+ ? ` · ${lastGitSync.skippedLocked.length} skipped (busy: a content write, provisioning or purge)`
156
+ : ''] }), lastGitSync.baseRefresh && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "base-refresh-outcome", children: ["Base branch: ", BASE_REFRESH_LABELS[lastGitSync.baseRefresh.outcome]] })), baseWarning && (_jsx(Text, { size: "xs", c: "orange", style: { whiteSpace: 'pre-line' }, "data-testid": "base-refresh-warning", children: baseWarning })), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
127
157
  }
128
158
  function TasksTab({ health }) {
129
159
  const { taskStatus, setTaskStatus, tasks, tasksLoading } = health;
@@ -160,6 +190,7 @@ function TasksTab({ health }) {
160
190
  function BranchesTab({ health }) {
161
191
  const { branchHealth, branchHealthLoading } = health;
162
192
  const entries = branchHealth?.entries ?? [];
193
+ const baseWarning = baseRefreshWarning(health.status?.workerStatus?.lastGitSync?.baseRefresh);
163
194
  const handleMarkMergedClick = (branchName) => {
164
195
  modals.openConfirmModal({
165
196
  title: 'Mark branch as merged',
@@ -193,9 +224,9 @@ function BranchesTab({ health }) {
193
224
  if (entries.length === 0) {
194
225
  return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
195
226
  }
196
- return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
227
+ return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
197
228
  }
198
- function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
229
+ function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge, }) {
199
230
  if (entry.kind === 'healthy' && entry.branch) {
200
231
  const b = entry.branch;
201
232
  // Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
@@ -207,7 +238,7 @@ function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
207
238
  const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
208
239
  return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusColorMap[b.status] ?? 'neutral', variant: "light", children: b.status }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(Tooltip, { label: b.syncFailureReason
209
240
  ? `${b.syncFailureReason} — retry from the Tasks tab`
210
- : 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
241
+ : 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(Tooltip, { label: baseWarning, multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
211
242
  .map((d) => `${d.id}: kept ${d.keptPath}; also on ${d.droppedPaths.join(', ')}`)
212
243
  .join('\n'), multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `duplicate-content-ids-${entry.dirName}`, children: [entry.duplicateContentIds.length, " duplicate ID", entry.duplicateContentIds.length === 1 ? '' : 's'] }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { size: "xs", variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
213
244
  }
@@ -0,0 +1,10 @@
1
+ export interface NoEditPermissionNoticeProps {
2
+ /** Logical path of the entry the user cannot edit. */
3
+ entryPath: string;
4
+ }
5
+ /**
6
+ * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
7
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
8
+ * locked or protected branch shows its own banner in the header and a read-only form instead.
9
+ */
10
+ export declare function NoEditPermissionNotice({ entryPath }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from '@mantine/core';
3
+ /**
4
+ * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
5
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
6
+ * locked or protected branch shows its own banner in the header and a read-only form instead.
7
+ */
8
+ export function NoEditPermissionNotice({ entryPath }) {
9
+ return (_jsx("div", { role: "status", "data-testid": "no-edit-permission-notice", style: {
10
+ display: 'flex',
11
+ alignItems: 'center',
12
+ justifyContent: 'center',
13
+ height: '100%',
14
+ padding: '0 var(--mantine-spacing-md)',
15
+ textAlign: 'center',
16
+ }, children: _jsxs(Text, { size: "sm", c: "dimmed", children: ["You don't have edit access to \"", entryPath, "\". Ask a CanopyCMS admin to grant it in Manage Permissions."] }) }));
17
+ }
@@ -13,6 +13,7 @@
13
13
  * `mutate()` to revalidate.
14
14
  */
15
15
  import useSWR from 'swr';
16
+ import { isNonApiResponse } from '../../api/client.js';
16
17
  /** Cache key for the branches list. */
17
18
  export const BRANCHES_KEY = 'canopy:branches';
18
19
  /**
@@ -21,9 +22,10 @@ export const BRANCHES_KEY = 'canopy:branches';
21
22
  */
22
23
  export async function fetchBranches(apiClient) {
23
24
  const result = await apiClient.branches.list();
24
- if (result.status === 404) {
25
+ if (result.status === 404 && !isNonApiResponse(result)) {
25
26
  // No branch endpoint available; stay branchless rather than erroring --
26
27
  // the branch dropdown stays clickable so the user can retry from there.
28
+ // A proxy's 404 page means the API was not reached, so it is an error.
27
29
  return { branches: [] };
28
30
  }
29
31
  if (!result.ok) {
@@ -14,7 +14,7 @@ export interface UseDraftManagerOptions {
14
14
  * shown (useEntryManager's `getEntryVersion`). Stamps each draft with the
15
15
  * version it was based on, and detects at save time that the token has
16
16
  * since moved on. Optional: without it no base versions are recorded and
17
- * conflict detection falls back entirely to the server's 409.
17
+ * conflict detection falls back entirely to saveEntry's token check.
18
18
  */
19
19
  getEntryVersion?: (contentId: string) => number | undefined;
20
20
  setBusy: (busy: boolean) => void;
@@ -316,7 +316,8 @@ export function useDraftManager(options) {
316
316
  return 'ok';
317
317
  const currentVersion = options.getEntryVersion?.(currentId);
318
318
  // No server version known for this entry at all -- nothing to compare
319
- // against, so this check has no opinion (the server's 409 still applies).
319
+ // against, so this check has no opinion: saveEntry refuses a save without
320
+ // a token, and the server refuses a version-less update.
320
321
  if (currentVersion === undefined)
321
322
  return 'ok';
322
323
  const base = draftBaseVersionsRef.current[currentId];
@@ -40,7 +40,7 @@ export async function listAllEntries(apiClient, branch) {
40
40
  ...(cursor !== undefined ? { cursor } : {}),
41
41
  });
42
42
  if (!result.ok || !result.data)
43
- throw new Error(`Refresh failed: ${result.status}`);
43
+ throw new Error(`Refresh failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
44
44
  const data = result.data;
45
45
  for (const entry of data.entries)
46
46
  byPath.set(entry.logicalPath, entry);
@@ -60,7 +60,7 @@ export async function fetchEntriesAndSchema(apiClient, branch, params) {
60
60
  // Fetch schema from schema API
61
61
  const schemaResult = await apiClient.schema.get({ branch });
62
62
  if (!schemaResult.ok || !schemaResult.data) {
63
- throw new Error(`Schema fetch failed: ${schemaResult.status}`);
63
+ throw new Error(`Schema fetch failed: ${schemaResult.status}${schemaResult.error ? ` — ${schemaResult.error}` : ''}`);
64
64
  }
65
65
  // Hydrate wire flatSchema: resolve schemaRef -> schema from entrySchemas dict
66
66
  const { entrySchemas } = schemaResult.data;
@@ -50,11 +50,9 @@ export function useEntryManager(options) {
50
50
  // case -- any first visit to a branch, for the whole duration of its fetch),
51
51
  // or a stale SWR slot. The editor then auto-selected one of those stale
52
52
  // entries (see the selection effect below), and a save could file its OCC
53
- // token under one contentId and look it up under another, silently skipping
54
- // conflict detection -- `content-store.ts` only compares mtimes when
55
- // `expectedVersion !== undefined`, so the write became a blind overwrite.
56
- // Deriving from a stamped record fixes that structurally instead of relying
57
- // on every code path remembering to clear.
53
+ // token under one contentId and look it up under another. Deriving from a
54
+ // stamped record fixes that structurally instead of relying on every code
55
+ // path remembering to clear.
58
56
  const [view, setView] = useState(() => ({
59
57
  branch: options.branchName,
60
58
  entries: options.initialEntries,
@@ -86,6 +84,9 @@ export function useEntryManager(options) {
86
84
  // branch can repopulate the map after the branch-change clear() below and
87
85
  // poison the next save with the old branch's mtime — a deterministic 409
88
86
  // ("modified by another editor") on save-after-switch, proven by e2e trace.
87
+ // The contentId half matches how useDraftManager keys drafts and loaded
88
+ // values, so a form value and its token always belong to the same file even
89
+ // when a path is deleted and recreated; it also survives a rename.
89
90
  const entryVersionsRef = useRef(new Map());
90
91
  const versionKey = (branch, contentId) => `${branch}:${contentId}`;
91
92
  // PER-BRANCH monotonic tokens guarding every commit of the fetched
@@ -192,7 +193,7 @@ export function useEntryManager(options) {
192
193
  path,
193
194
  });
194
195
  if (!result.ok)
195
- throw new Error(`Load failed: ${result.status}`);
196
+ throw new Error(`Load failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
196
197
  // Capture OCC version token for next save
197
198
  if (entry.contentId && typeof result.data?.version === 'number') {
198
199
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
@@ -220,18 +221,23 @@ export function useEntryManager(options) {
220
221
  };
221
222
  if (entry.entryType)
222
223
  writeParams.entryType = entry.entryType;
223
- const expectedVersion = entry.contentId
224
- ? entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId))
225
- : undefined;
224
+ const expectedVersion = entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId));
225
+ // Every entry saved here already exists (creates go through handleCreateModalSubmit), so
226
+ // no token means this entry was never successfully read on this branch and nothing could check the save
227
+ // against other editors' work. Refused as a conflict; the server refuses it too.
228
+ if (expectedVersion === undefined) {
229
+ throw new SaveApiError(409, 'This entry has not been loaded from the server, so the save cannot be checked ' +
230
+ "against other editors' changes. Reload it and try again.");
231
+ }
226
232
  const writeBody = {
227
233
  ...payload,
228
- ...(expectedVersion !== undefined ? { expectedVersion } : {}),
234
+ expectedVersion,
229
235
  };
230
236
  const result = await apiClient.content.write(writeParams, writeBody);
231
237
  if (!result.ok)
232
238
  throw new SaveApiError(result.status, result.error, result.fieldErrors);
233
239
  // Update stored version token from write response
234
- if (entry.contentId && typeof result.data?.version === 'number') {
240
+ if (typeof result.data?.version === 'number') {
235
241
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
236
242
  }
237
243
  // Warning-level issues from the adopter's validateEntry hook: saved, but surface them
@@ -2,7 +2,7 @@ import { countTitleFields, findInvalidTitleFields, findTitleFieldsInLists, } fro
2
2
  import { countBodyFields, findInvalidBodyFields, findReservedBodyFieldName, } from './utils/body-field.js';
3
3
  import { RESOLVED_REFERENCE_KEYS } from './entry-schema.js';
4
4
  import { flattenGroupFields } from './utils/flatten-group-fields.js';
5
- import { ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, } from './config/validation.js';
5
+ import { ensureSelectFieldsHaveOptions, ensureReferenceFieldsHaveScope, ensureItemTitleFieldsExist, ensureNoFlattenedFieldNameCollisions, ensureNoGroupsInsideComplexFields, } from './config/validation.js';
6
6
  /** Look up a field's type by dotted path (e.g., "meta.order").
7
7
  * Groups are transparent — their children are searched at the same path level. */
8
8
  function findFieldType(fields, dottedPath) {
@@ -94,6 +94,7 @@ export function createEntrySchemaRegistry(registry) {
94
94
  }
95
95
  ensureSelectFieldsHaveOptions(schema);
96
96
  ensureReferenceFieldsHaveScope(schema);
97
+ ensureItemTitleFieldsExist(schema);
97
98
  ensureNoGroupsInsideComplexFields(schema);
98
99
  ensureNoFlattenedFieldNameCollisions(schema, `entry schema "${key}"`);
99
100
  }
@@ -69,6 +69,17 @@ export declare class GitRemoteRefMissingError extends Error {
69
69
  */
70
70
  gitError?: unknown | undefined);
71
71
  }
72
+ /**
73
+ * No git remote is configured and the one the strategy auto-detects does not
74
+ * exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
75
+ * its first boot, so until then every request that needs a workspace fails
76
+ * here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
77
+ * The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
78
+ */
79
+ export declare class RemoteNotReadyError extends Error {
80
+ readonly expectedRemotePath: string;
81
+ constructor(expectedRemotePath: string);
82
+ }
72
83
  export interface ResolveRemoteUrlOptions {
73
84
  mode: OperatingMode;
74
85
  remoteUrl?: string;
@@ -283,6 +294,8 @@ export declare class GitManager {
283
294
  rebaseOntoBase(): Promise<void>;
284
295
  private rebaseOntoBaseInner;
285
296
  add(files: string | string[]): Promise<void>;
297
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
298
+ addAllExceptCanopyState(): Promise<void>;
286
299
  commit(message: string): Promise<void>;
287
300
  push(branch?: string): Promise<void>;
288
301
  /**
@@ -303,6 +316,13 @@ export declare class GitManager {
303
316
  * ("couldn't find remote ref"); that counts as "ahead", not an error.
304
317
  */
305
318
  hasUnpushedCommits(branch?: string): Promise<boolean>;
319
+ /**
320
+ * Repo-relative paths that differ between HEAD and its merge base with the
321
+ * base branch: everything the branch changes, across all of its commits.
322
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
323
+ * constraint.
324
+ */
325
+ listChangedPathsSinceBase(): Promise<string[]>;
306
326
  ensureAuthor(author: {
307
327
  name: string;
308
328
  email: string;