canopycms 0.0.67 → 0.0.68-int.94

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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Schema Store - handles reading and writing .collection.json files.
3
- * All mutations are branch-specific (like content edits).
2
+ * Schema Store - reads and writes .collection.json files, per branch.
4
3
  *
5
4
  * ## Concurrency
6
5
  *
@@ -16,11 +15,11 @@
16
15
  *
17
16
  * Protection is instead layer 1 ({@link withLock}) + layer 3
18
17
  * ({@link withOccFileLock}) on one COARSE per-branch SURROGATE lock path
19
- * OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema`, see
20
- * `withSchemaLock`), covering every schema mutation on the branch including
21
- * multi-file ones. Layer 2 (OCC read-back) is skipped, since there's no
22
- * version field to check; layer 4 (generation marker) guards the schema
23
- * CACHE separately, not this write path.
18
+ * OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema`), covering
19
+ * every schema mutation including multi-file ones, inside the [SYNC-C1]
20
+ * content-write lock the worker's rebase also takes (`withBranchSchemaLock`).
21
+ * Layer 2 (OCC read-back) is skipped, since there's no version field to
22
+ * check; layer 4 (generation marker) guards the schema CACHE separately.
24
23
  *
25
24
  * Accepted residual: `deleteBranch` does not take this lock before its
26
25
  * recursive `rm`, so an in-flight write can race a concurrent branch
@@ -182,7 +181,30 @@ declare const updateEntryTypeInputSchema: z.ZodObject<{
182
181
  * into a 409 so the editor can retry rather than surfacing a raw 400.
183
182
  */
184
183
  export declare class SchemaStoreBusyError extends Error {
185
- constructor(message?: string);
184
+ /** As on `ContentWriteLockBusyError`: `'unknown'` means the mutation ran and may have landed. */
185
+ readonly outcome: 'not-run' | 'unknown';
186
+ constructor(message?: string,
187
+ /** As on `ContentWriteLockBusyError`: `'unknown'` means the mutation ran and may have landed. */
188
+ outcome?: 'not-run' | 'unknown');
189
+ }
190
+ /**
191
+ * Run `fn` holding every lock a schema mutation needs on `branchRoot`, in the
192
+ * global order (docs/concurrency.md, "Lock acquisition order"): the branch's
193
+ * content-write lock, then the `.canopy-meta/schema` surrogate's in-process
194
+ * mutex, then its lockfile. Shared with `cli/migrate.ts` so the two cannot
195
+ * drift.
196
+ *
197
+ * NOT re-entrant, and nothing inside `fn` may take the content-write lock
198
+ * (no `ContentStore` writes): proper-lockfile refuses a second acquisition in
199
+ * the same process, so a nested one burns its wait and fails busy.
200
+ *
201
+ * Errors propagate untranslated: `ContentWriteLockBusyError` (contention, or
202
+ * a compromise reported after `fn` completed) and `OccWriteConflictError`.
203
+ */
204
+ export declare function withBranchSchemaLock<T>(branchRoot: string, fn: () => Promise<T>, contentWriteLockWaitMs?: number): Promise<T>;
205
+ export interface SchemaOpsOptions {
206
+ /** Bounded wait for the branch's content-write lock; defaults to `DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS`. */
207
+ contentWriteLockWaitMs?: number;
186
208
  }
187
209
  export declare class SchemaOps {
188
210
  private readonly contentRoot;
@@ -199,21 +221,19 @@ export declare class SchemaOps {
199
221
  * and never match the root collection's logical path).
200
222
  */
201
223
  private readonly contentRootName;
202
- /** Coarse per-branch surrogate lock path — see the module doc comment. */
203
- private readonly schemaLockPath;
204
- constructor(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry, services?: CanopyServices | undefined, branchRoot?: string);
224
+ /** See SchemaOpsOptions.contentWriteLockWaitMs. */
225
+ private readonly contentWriteLockWaitMs;
226
+ constructor(contentRoot: string, entrySchemaRegistry: EntrySchemaRegistry, services?: CanopyServices | undefined, branchRoot?: string, options?: SchemaOpsOptions);
205
227
  /**
206
- * Serialize an entire read-modify-write schema mutation behind the coarse
207
- * per-branch surrogate lock described in the module doc comment: layer 1
208
- * ({@link withLock}, in-process FIFO mutex) wraps layer 3
209
- * ({@link withOccFileLock}, cross-process/cross-host mkdir-based mutual
210
- * exclusion, immune to NFS attribute caching), same structure as
211
- * `BranchMetadataFileManager.save()` in branch-metadata.ts.
228
+ * Serialize an entire read-modify-write schema mutation behind
229
+ * {@link withBranchSchemaLock}: the content-write lock, then the surrogate
230
+ * ({@link withLock} wrapping {@link withOccFileLock}, as in
231
+ * `BranchMetadataFileManager.save()`).
212
232
  *
213
233
  * NOT re-entrant — callers must never invoke this from inside a callback
214
- * already running under it, or `withLock` deadlocks waiting on itself;
215
- * that's why `updateOrderInner` calls `updateCollectionInner` directly
216
- * instead of the public `updateCollection`.
234
+ * already running under it, or the nested acquisition fails busy; that's
235
+ * why `updateOrderInner` calls `updateCollectionInner` directly instead of
236
+ * the public `updateCollection`.
217
237
  *
218
238
  * Phantom-resurrection guard: `branchRoot` can be removed by a concurrent
219
239
  * `deleteBranch` between the caller resolving its BranchContext and this
@@ -226,7 +246,13 @@ export declare class SchemaOps {
226
246
  * since `deleteBranch` never takes this lock (see the module doc comment).
227
247
  *
228
248
  * Translation happens ONLY at this boundary: inner code always sees the
229
- * raw {@link OccWriteConflictError} bubble up here untranslated.
249
+ * raw {@link OccWriteConflictError} bubble up here untranslated, and a
250
+ * {@link ContentWriteLockBusyError} becomes a `SchemaStoreBusyError` with
251
+ * its message, so every caller's 409 mapping covers both.
252
+ *
253
+ * A content-lock compromise is reported after `fn` completed, so the
254
+ * mutation is on disk while the caller's post-lock `invalidateSchemaCache()`
255
+ * never runs; this runs it before throwing.
230
256
  */
231
257
  private withSchemaLock;
232
258
  /**
@@ -322,7 +348,7 @@ export declare class SchemaOps {
322
348
  * Body of updateCollection, holding the schema lock for its full
323
349
  * read-modify-write. Called directly (not via the public `updateCollection`)
324
350
  * by `updateOrderInner` for non-root collections — `withSchemaLock` is NOT
325
- * re-entrant, so going through the public method there would deadlock.
351
+ * re-entrant, so going through the public method there would fail busy.
326
352
  *
327
353
  * `collectionPath` here is always already normalized (content-root prefix
328
354
  * stripped, if it had one) by whichever public method reached this — either
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Schema Store - handles reading and writing .collection.json files.
3
- * All mutations are branch-specific (like content edits).
2
+ * Schema Store - reads and writes .collection.json files, per branch.
4
3
  *
5
4
  * ## Concurrency
6
5
  *
@@ -16,11 +15,11 @@
16
15
  *
17
16
  * Protection is instead layer 1 ({@link withLock}) + layer 3
18
17
  * ({@link withOccFileLock}) on one COARSE per-branch SURROGATE lock path
19
- * OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema`, see
20
- * `withSchemaLock`), covering every schema mutation on the branch including
21
- * multi-file ones. Layer 2 (OCC read-back) is skipped, since there's no
22
- * version field to check; layer 4 (generation marker) guards the schema
23
- * CACHE separately, not this write path.
18
+ * OUTSIDE the content tree (`{branchRoot}/.canopy-meta/schema`), covering
19
+ * every schema mutation including multi-file ones, inside the [SYNC-C1]
20
+ * content-write lock the worker's rebase also takes (`withBranchSchemaLock`).
21
+ * Layer 2 (OCC read-back) is skipped, since there's no version field to
22
+ * check; layer 4 (generation marker) guards the schema CACHE separately.
24
23
  *
25
24
  * Accepted residual: `deleteBranch` does not take this lock before its
26
25
  * recursive `rm`, so an in-flight write can race a concurrent branch
@@ -34,6 +33,7 @@ import { z } from 'zod';
34
33
  import { atomicWriteFile } from '../utils/atomic-write.js';
35
34
  import { withLock } from '../utils/async-mutex.js';
36
35
  import { createDebugLogger } from '../utils/debug.js';
36
+ import { ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from '../utils/content-write-lock.js';
37
37
  import { getErrorMessage, isNotFoundError } from '../utils/error.js';
38
38
  import { withOccFileLock, OccWriteConflictError } from '../utils/occ-json-write.js';
39
39
  import { resolveCollectionPath } from '../content-id-index.js';
@@ -107,13 +107,34 @@ const log = createDebugLogger({ prefix: 'SchemaOps' });
107
107
  * into a 409 so the editor can retry rather than surfacing a raw 400.
108
108
  */
109
109
  export class SchemaStoreBusyError extends Error {
110
- constructor(message = 'Schema is being modified by another operation, try again') {
110
+ constructor(message = 'Schema is being modified by another operation, try again',
111
+ /** As on `ContentWriteLockBusyError`: `'unknown'` means the mutation ran and may have landed. */
112
+ outcome = 'not-run') {
111
113
  super(message);
114
+ this.outcome = outcome;
112
115
  this.name = 'SchemaStoreBusyError';
113
116
  }
114
117
  }
118
+ /**
119
+ * Run `fn` holding every lock a schema mutation needs on `branchRoot`, in the
120
+ * global order (docs/concurrency.md, "Lock acquisition order"): the branch's
121
+ * content-write lock, then the `.canopy-meta/schema` surrogate's in-process
122
+ * mutex, then its lockfile. Shared with `cli/migrate.ts` so the two cannot
123
+ * drift.
124
+ *
125
+ * NOT re-entrant, and nothing inside `fn` may take the content-write lock
126
+ * (no `ContentStore` writes): proper-lockfile refuses a second acquisition in
127
+ * the same process, so a nested one burns its wait and fails busy.
128
+ *
129
+ * Errors propagate untranslated: `ContentWriteLockBusyError` (contention, or
130
+ * a compromise reported after `fn` completed) and `OccWriteConflictError`.
131
+ */
132
+ export async function withBranchSchemaLock(branchRoot, fn, contentWriteLockWaitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS) {
133
+ const schemaLockPath = path.join(path.resolve(branchRoot), '.canopy-meta', 'schema');
134
+ return withContentWriteLock(branchRoot, () => withLock(schemaLockPath, () => withOccFileLock(schemaLockPath, fn)), contentWriteLockWaitMs);
135
+ }
115
136
  export class SchemaOps {
116
- constructor(contentRoot, entrySchemaRegistry, services, branchRoot) {
137
+ constructor(contentRoot, entrySchemaRegistry, services, branchRoot, options = {}) {
117
138
  this.contentRoot = contentRoot;
118
139
  this.entrySchemaRegistry = entrySchemaRegistry;
119
140
  this.services = services;
@@ -130,20 +151,19 @@ export class SchemaOps {
130
151
  .relative(this.branchRoot, resolvedContentRoot)
131
152
  .split(path.sep)
132
153
  .join('/');
133
- this.schemaLockPath = path.join(this.branchRoot, '.canopy-meta', 'schema');
154
+ this.contentWriteLockWaitMs =
155
+ options.contentWriteLockWaitMs ?? DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS;
134
156
  }
135
157
  /**
136
- * Serialize an entire read-modify-write schema mutation behind the coarse
137
- * per-branch surrogate lock described in the module doc comment: layer 1
138
- * ({@link withLock}, in-process FIFO mutex) wraps layer 3
139
- * ({@link withOccFileLock}, cross-process/cross-host mkdir-based mutual
140
- * exclusion, immune to NFS attribute caching), same structure as
141
- * `BranchMetadataFileManager.save()` in branch-metadata.ts.
158
+ * Serialize an entire read-modify-write schema mutation behind
159
+ * {@link withBranchSchemaLock}: the content-write lock, then the surrogate
160
+ * ({@link withLock} wrapping {@link withOccFileLock}, as in
161
+ * `BranchMetadataFileManager.save()`).
142
162
  *
143
163
  * NOT re-entrant — callers must never invoke this from inside a callback
144
- * already running under it, or `withLock` deadlocks waiting on itself;
145
- * that's why `updateOrderInner` calls `updateCollectionInner` directly
146
- * instead of the public `updateCollection`.
164
+ * already running under it, or the nested acquisition fails busy; that's
165
+ * why `updateOrderInner` calls `updateCollectionInner` directly instead of
166
+ * the public `updateCollection`.
147
167
  *
148
168
  * Phantom-resurrection guard: `branchRoot` can be removed by a concurrent
149
169
  * `deleteBranch` between the caller resolving its BranchContext and this
@@ -156,7 +176,13 @@ export class SchemaOps {
156
176
  * since `deleteBranch` never takes this lock (see the module doc comment).
157
177
  *
158
178
  * Translation happens ONLY at this boundary: inner code always sees the
159
- * raw {@link OccWriteConflictError} bubble up here untranslated.
179
+ * raw {@link OccWriteConflictError} bubble up here untranslated, and a
180
+ * {@link ContentWriteLockBusyError} becomes a `SchemaStoreBusyError` with
181
+ * its message, so every caller's 409 mapping covers both.
182
+ *
183
+ * A content-lock compromise is reported after `fn` completed, so the
184
+ * mutation is on disk while the caller's post-lock `invalidateSchemaCache()`
185
+ * never runs; this runs it before throwing.
160
186
  */
161
187
  async withSchemaLock(fn) {
162
188
  try {
@@ -168,13 +194,31 @@ export class SchemaOps {
168
194
  }
169
195
  throw err;
170
196
  }
197
+ let landed = false;
171
198
  try {
172
- return await withLock(this.schemaLockPath, () => withOccFileLock(this.schemaLockPath, fn));
199
+ return await withBranchSchemaLock(this.branchRoot, async () => {
200
+ const result = await fn();
201
+ landed = true;
202
+ return result;
203
+ }, this.contentWriteLockWaitMs);
173
204
  }
174
205
  catch (err) {
175
206
  if (err instanceof OccWriteConflictError) {
176
207
  throw new SchemaStoreBusyError();
177
208
  }
209
+ if (err instanceof ContentWriteLockBusyError) {
210
+ if (landed) {
211
+ await this.invalidateSchemaCache().catch((invalidateErr) => {
212
+ log.warn('schema-cache', 'Invalidation after a lost content-write lock failed', {
213
+ branchRoot: this.branchRoot,
214
+ error: getErrorMessage(invalidateErr),
215
+ });
216
+ });
217
+ }
218
+ throw new SchemaStoreBusyError(err.outcome === 'unknown'
219
+ ? 'This branch was being synced while the schema change was written, so it may or may not have been recorded. Reload before changing the schema again.'
220
+ : err.message, err.outcome);
221
+ }
178
222
  throw err;
179
223
  }
180
224
  }
@@ -461,7 +505,7 @@ export class SchemaOps {
461
505
  * Body of updateCollection, holding the schema lock for its full
462
506
  * read-modify-write. Called directly (not via the public `updateCollection`)
463
507
  * by `updateOrderInner` for non-root collections — `withSchemaLock` is NOT
464
- * re-entrant, so going through the public method there would deadlock.
508
+ * re-entrant, so going through the public method there would fail busy.
465
509
  *
466
510
  * `collectionPath` here is always already normalized (content-root prefix
467
511
  * stripped, if it had one) by whichever public method reached this — either
@@ -24,11 +24,16 @@ import { GitManager } from './git-manager.js';
24
24
  import { BranchRegistry } from './branch-registry.js';
25
25
  import { type GitHubService } from './github-service.js';
26
26
  import { BranchSchemaCache } from './branch-schema-cache.js';
27
+ import { type SubmissionEditor } from './submission-attribution.js';
27
28
  /**
28
29
  * Bootstrap admin IDs from the environment. These users are treated as Admins
29
30
  * whatever their group membership.
30
31
  */
31
32
  export declare const getBootstrapAdminIds: () => Set<string>;
33
+ export interface SubmitBranchResult {
34
+ /** Repo-relative paths the branch changes, excluding canopycms runtime metadata. */
35
+ changedPaths: string[];
36
+ }
32
37
  export interface CanopyServices {
33
38
  config: CanopyConfig;
34
39
  /**
@@ -58,27 +63,34 @@ export interface CanopyServices {
58
63
  githubService?: GitHubService;
59
64
  /** Bootstrap admin user IDs that are always treated as Admins */
60
65
  bootstrapAdminIds: Set<string>;
61
- /** Commit files to git with automatic author handling */
66
+ /**
67
+ * Commit files to git with automatic author handling. Holds the branch's
68
+ * content-write lock; rejects with `ContentWriteLockBusyError` when busy.
69
+ */
62
70
  commitFiles: (options: {
63
71
  context: BranchContext;
64
72
  files: string | string[];
65
73
  message: string;
66
74
  }) => Promise<void>;
67
- /** Submit branch: commit all changes and push to remote */
75
+ /**
76
+ * Submit branch: commit all changes (with the submitter's trailers) and push
77
+ * to remote. Resolves with every path the branch changes against its base.
78
+ * Holds the branch's content-write lock; rejects with
79
+ * `ContentWriteLockBusyError` when busy.
80
+ */
68
81
  submitBranch: (options: {
69
82
  context: BranchContext;
83
+ submitter?: SubmissionEditor;
70
84
  message?: string;
71
- }) => Promise<void>;
72
- /** Commit to settings branch (for permissions/groups), with optional PR creation */
85
+ }) => Promise<SubmitBranchResult>;
86
+ /** Commit to the settings branch (for permissions/groups) and push it; never opens a PR */
73
87
  commitToSettingsBranch: (options: {
74
88
  branchRoot: string;
75
89
  files: string | string[];
76
90
  message: string;
77
- createPR?: boolean;
78
91
  }) => Promise<{
79
92
  committed: boolean;
80
93
  pushed: boolean;
81
- prUrl?: string;
82
94
  error?: string;
83
95
  syncStatus?: 'pending-sync' | 'synced' | 'sync-failed';
84
96
  }>;
package/dist/services.js CHANGED
@@ -9,8 +9,13 @@ import { operatingStrategy } from './operating-mode/index.js';
9
9
  import { BranchSchemaCache } from './branch-schema-cache.js';
10
10
  import { enqueueTask } from './task-queue/cms-task-queue.js';
11
11
  import { getTaskQueueDir } from './task-queue/task-queue-config.js';
12
- import { detectHeadBranch } from './utils/git.js';
12
+ import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
13
13
  import { readsFromCheckout } from './build-mode.js';
14
+ import { timeRequestPhase } from './utils/request-timing.js';
15
+ import { BRANCH_META_DIR } from './branch-metadata-file.js';
16
+ import { appendTrailers, buildEditorTrailers, } from './submission-attribution.js';
17
+ import { getErrorMessage, redactCredentials } from './utils/error.js';
18
+ import { withContentWriteLock } from './utils/content-write-lock.js';
14
19
  /**
15
20
  * A per-instance active-branch detector with its own 5s TTL cache, in priority
16
21
  * order: an explicitly configured value; `defaultBaseBranch ?? 'main'` with no
@@ -97,7 +102,7 @@ async function _createCanopyServicesInternal(config, options) {
97
102
  const branchSchemaCache = options.branchSchemaCache ?? new BranchSchemaCache(config.mode);
98
103
  const checkBranchAccess = createCheckBranchAccess(config.defaultBranchAccess ?? 'deny', config);
99
104
  // Content access loads permissions dynamically from the settings branch (orphan git branch)
100
- const getSettingsBranchRoot = options.getSettingsBranchRoot ??
105
+ const ensureSettingsBranchRoot = options.getSettingsBranchRoot ??
101
106
  (async () => {
102
107
  const strategy = operatingStrategy(config.mode);
103
108
  const settingsRoot = strategy.getSettingsRoot();
@@ -111,9 +116,10 @@ async function _createCanopyServicesInternal(config, options) {
111
116
  });
112
117
  return settingsRoot;
113
118
  });
119
+ const getSettingsBranchRoot = () => timeRequestPhase('settingsRoot', ensureSettingsBranchRoot);
114
120
  const contentAccessDeps = {
115
121
  checkBranchAccess,
116
- loadPathPermissions,
122
+ loadPathPermissions: (repoRoot, mode) => timeRequestPhase('permissions', () => loadPathPermissions(repoRoot, mode)),
117
123
  defaultPathAccess: config.defaultPathAccess ?? 'deny',
118
124
  mode: config.mode,
119
125
  getSettingsBranchRoot,
@@ -137,8 +143,13 @@ async function _createCanopyServicesInternal(config, options) {
137
143
  name: config.gitBotAuthorName,
138
144
  email: config.gitBotAuthorEmail,
139
145
  });
140
- await git.add(options.files);
141
- await git.commit(options.message);
146
+ // [SYNC-C1] Unlocked, a commit made while a rebase is stopped on a conflict
147
+ // lands on its detached head with the rebase's half-resolved index, and the
148
+ // rebase's `--abort` discards it.
149
+ await withContentWriteLock(options.context.branchRoot, async () => {
150
+ await git.add(options.files);
151
+ await git.commit(options.message);
152
+ });
142
153
  };
143
154
  const submitBranch = async (options) => {
144
155
  // Defense-in-depth: refuse to push the base branch to itself even if the
@@ -156,23 +167,53 @@ async function _createCanopyServicesInternal(config, options) {
156
167
  name: config.gitBotAuthorName,
157
168
  email: config.gitBotAuthorEmail,
158
169
  });
159
- await git.checkoutBranch(options.context.branch.name);
160
- const status = await git.status();
161
- // Commit and push answer two DIFFERENT questions. Committing cleans the
162
- // working tree, so one combined "tree is dirty" gate makes a retry after a
163
- // failed push a silent no-op: nothing left to commit, the block is skipped,
164
- // and success is reported though the commit never reached the remote. Push
165
- // whenever there is something new to send — we just committed, or the local
166
- // branch already had unpushed commits from an earlier attempt.
167
- let committed = false;
168
- if (status.files.length > 0) {
169
- await git.add('.');
170
- await git.commit(options.message ?? `Submit ${options.context.branch.name}`);
171
- committed = true;
170
+ // [SYNC-C1] Commits the whole working tree, so it holds the branch's
171
+ // content-write lock from checkout through push. Unlocked, its checkout
172
+ // succeeds while the worker's rebase is stopped on a conflict, the commit
173
+ // lands on the branch, and the rebase's `--abort` resets the branch past it
174
+ // after this reported success. The push stays inside so no rebase rewrites
175
+ // the commit between commit and push.
176
+ const submitted = await withContentWriteLock(options.context.branchRoot, async () => {
177
+ await git.checkoutBranch(options.context.branch.name);
178
+ const status = await git.status();
179
+ // Commit and push answer two DIFFERENT questions. Committing cleans the
180
+ // working tree, so one combined "tree is dirty" gate makes a retry after a
181
+ // failed push a silent no-op: nothing left to commit, the block is skipped,
182
+ // and success is reported though the commit never reached the remote. Push
183
+ // whenever there is something new to send — we just committed, or the local
184
+ // branch already had unpushed commits from an earlier attempt.
185
+ // canopycms's own state under .canopy-meta/ is never content, so it neither
186
+ // makes a commit worth creating nor gets staged into one.
187
+ let committed = false;
188
+ if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
189
+ await git.addAllExceptCanopyState();
190
+ const trailers = buildEditorTrailers(options.submitter ? [options.submitter] : [], {
191
+ editedBy: config.gitEditedByTrailers ?? true,
192
+ coAuthoredBy: config.gitCoAuthoredByTrailers ?? false,
193
+ });
194
+ await git.commit(appendTrailers(options.message ?? `Submit ${options.context.branch.name}`, trailers));
195
+ committed = true;
196
+ }
197
+ if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
198
+ await git.push(options.context.branch.name);
199
+ }
200
+ return status;
201
+ });
202
+ // The list only feeds the PR body, so a failure to compute it falls back to
203
+ // this submit's own working-tree changes rather than failing a submit that
204
+ // has already been pushed.
205
+ let changedPaths;
206
+ try {
207
+ changedPaths = await git.listChangedPathsSinceBase();
172
208
  }
173
- if (committed || (await git.hasUnpushedCommits(options.context.branch.name))) {
174
- await git.push(options.context.branch.name);
209
+ catch (err) {
210
+ console.warn(`CanopyCMS: Could not list the changes on ${options.context.branch.name} against its base; ` +
211
+ "the PR body lists only this submit's changes:", redactCredentials(getErrorMessage(err)));
212
+ changedPaths = submitted.files.map((f) => f.path);
175
213
  }
214
+ return {
215
+ changedPaths: changedPaths.filter((p) => !p.startsWith(`${BRANCH_META_DIR}/`)),
216
+ };
176
217
  };
177
218
  // Must be initialized before closures that reference it (commitToSettingsBranch)
178
219
  let githubService;
@@ -233,57 +274,31 @@ async function _createCanopyServicesInternal(config, options) {
233
274
  error: error instanceof Error ? error.message : 'Push failed',
234
275
  };
235
276
  }
236
- // Create or update PR — dual-path like content branches (api/github-sync.ts)
237
- if (options.createPR !== false) {
238
- // Permissions and groups are read live from the settings workspace
239
- // (getSettingsBranchRoot), never from this PR's base branch, so the
240
- // change took effect when it was committed and pushed above, before
241
- // this PR existed. Merging re-activates nothing; it only records the
242
- // change on `base` for review and audit history.
243
- const settingsPRBody = 'Automated PR for permission and group changes. These changes already took ' +
244
- 'effect in the CMS when they were saved — merging this PR does not change ' +
245
- "what's live; it only records the change here for review and audit history.";
246
- // Direct path: githubService available (has internet)
247
- if (githubService) {
248
- let prUrl;
249
- try {
250
- // Settings-branch PRs never pass markReadyIfDraft: a settings sync
251
- // has no explicit "submit for review" step the way a content submit
252
- // does, so an existing draft PR stays draft until an admin says so.
253
- const result = await githubService.createOrUpdatePR({
254
- head: settingsBranch,
255
- base: config.defaultBaseBranch ?? 'main',
256
- title: 'Update permissions and groups',
257
- body: settingsPRBody,
258
- });
259
- prUrl = result.url;
260
- }
261
- catch (err) {
262
- console.warn('Failed to create/update PR:', err);
263
- return { committed: true, pushed: true, syncStatus: 'sync-failed' };
264
- }
265
- return { committed: true, pushed: true, prUrl, syncStatus: 'synced' };
266
- }
267
- // Async path: queue task for worker (prod Lambda has no internet)
268
- const taskDir = getTaskQueueDir(config);
269
- try {
270
- await enqueueTask(taskDir, {
271
- action: 'push-and-create-or-update-pr',
272
- payload: {
273
- branch: settingsBranch,
274
- baseBranch: config.defaultBaseBranch ?? 'main',
275
- title: 'Update permissions and groups',
276
- body: settingsPRBody,
277
- },
278
- });
279
- return { committed: true, pushed: true, syncStatus: 'pending-sync' };
280
- }
281
- catch (err) {
282
- console.warn('Failed to enqueue settings PR task:', err);
283
- return { committed: true, pushed: true, syncStatus: 'sync-failed' };
284
- }
277
+ // The settings branch is an orphan: it shares no history with the base
278
+ // branch, so GitHub rejects a PR for it. The change took effect when it
279
+ // was committed and pushed above (permissions and groups are read live
280
+ // from the settings workspace); the only GitHub step is mirroring the
281
+ // branch, so there is never a PR.
282
+ if (!operatingStrategy(mode).supportsPullRequests()) {
283
+ return { committed: true, pushed: true };
284
+ }
285
+ // With a githubService the push above already reached GitHub: both it and
286
+ // the workspace remote come from `defaultRemoteUrl`. Without one (prod
287
+ // Lambda has no internet) the worker pushes it.
288
+ if (githubService) {
289
+ return { committed: true, pushed: true, syncStatus: 'synced' };
290
+ }
291
+ try {
292
+ await enqueueTask(getTaskQueueDir(config), {
293
+ action: 'push-branch',
294
+ payload: { branch: settingsBranch },
295
+ });
296
+ return { committed: true, pushed: true, syncStatus: 'pending-sync' };
297
+ }
298
+ catch (err) {
299
+ console.warn('Failed to enqueue settings push task:', err);
300
+ return { committed: true, pushed: true, syncStatus: 'sync-failed' };
285
301
  }
286
- return { committed: true, pushed: true };
287
302
  }
288
303
  catch (error) {
289
304
  return {
@@ -9,6 +9,36 @@ const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
9
9
  // In-memory lock against concurrent init within one process. One lock suffices,
10
10
  // unlike content branches, which need one per branch.
11
11
  let settingsInitLock = null;
12
+ /**
13
+ * Settings workspaces this process has fully ensured, keyed by {@link ensuredKey}. A hit
14
+ * skips the guard, the init lock and initializeWorkspace's dozen git subprocesses, which
15
+ * otherwise ran on every API request. It is sound because settings freshness never comes from
16
+ * that pass: it reads the remote only to provision the settings branch or repair an empty one
17
+ * (GitManager.createOrphanSettingsBranch), saves pull, and every process reads the one shared
18
+ * workspace. Everything it verified is fixed for the process: the settings-branch name resolves once
19
+ * from config, the remote URL comes from config, and nothing in CanopyCMS checks the settings
20
+ * workspace out onto another branch. groups.json and permissions.json are still read from
21
+ * disk on every request; only the provisioning is memoized. Failures are never recorded. In
22
+ * dev, a hit also skips re-seeding a deleted `.canopy-dev/remote.git`; a dev-server restart
23
+ * re-seeds it.
24
+ *
25
+ * A hit still reads `.git/HEAD`, so a workspace removed, re-cloned onto another branch, or
26
+ * caught mid-clone by another process (HEAD still on the base branch) misses and runs the full
27
+ * path, guard and lock included.
28
+ */
29
+ const ensuredSettingsWorkspaces = new Set();
30
+ function ensuredKey(options) {
31
+ return `${path.resolve(options.settingsRoot)}\0${options.branchName}`;
32
+ }
33
+ async function checkedOutOn(settingsRoot, branchName) {
34
+ try {
35
+ const head = await fs.readFile(path.join(settingsRoot, '.git', 'HEAD'), 'utf-8');
36
+ return head.trim() === `ref: refs/heads/${branchName}`;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
12
42
  const SETTINGS_INIT_LOCK_DIR = '.settings-init';
13
43
  const SETTINGS_INIT_LOCK_NAME = 'lock';
14
44
  /**
@@ -54,8 +84,8 @@ async function settingsFilesPresent(settingsRoot) {
54
84
  * branch name.
55
85
  *
56
86
  * Without it, GitManager.initializeWorkspace sees an existing .git, skips the
57
- * clone, and calls createOrphanSettingsBranch(branchName); for an unknown name
58
- * git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
87
+ * clone, and calls createOrphanSettingsBranch(branchName); for a name neither the
88
+ * workspace nor its remote has, git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
59
89
  * Orphan branches share no history, so that is not a migration — it
60
90
  * PERMANENTLY WIPES permissions.json/groups.json with nothing to recover from.
61
91
  * The trigger is almost always deploymentName / settingsBranch /
@@ -103,8 +133,9 @@ async function assertSettingsWorkspaceIdentity(options) {
103
133
  `deploymentName, settingsBranch, or CANOPYCMS_DEPLOYMENT_NAME changed on a ` +
104
134
  `deployment that already has a populated settings workspace. To resolve: restore ` +
105
135
  `the previous value so this resolves back to ` +
106
- `'${currentBranch ?? options.branchName}', or, if starting fresh is genuinely ` +
107
- `intended, move ${options.settingsRoot} aside manually first.`);
136
+ `'${currentBranch ?? options.branchName}', or, if switching is genuinely intended, ` +
137
+ `move ${options.settingsRoot} aside manually first: the next start checks out ` +
138
+ `'${options.branchName}' from the remote, or starts it empty if the remote has none.`);
108
139
  }
109
140
  }
110
141
  /**
@@ -122,6 +153,12 @@ export class SettingsWorkspaceManager {
122
153
  this.config = config;
123
154
  }
124
155
  async ensureGitWorkspace(options) {
156
+ const key = ensuredKey(options);
157
+ if (ensuredSettingsWorkspaces.has(key)) {
158
+ if (await checkedOutOn(options.settingsRoot, options.branchName))
159
+ return;
160
+ ensuredSettingsWorkspaces.delete(key);
161
+ }
125
162
  return log.timed('workspace', 'ensureGitWorkspace', async () => {
126
163
  // Layer 1: In-memory lock (prevents redundant async calls within same process)
127
164
  if (settingsInitLock) {
@@ -170,6 +207,7 @@ export class SettingsWorkspaceManager {
170
207
  gitBotAuthorName: this.config.gitBotAuthorName,
171
208
  gitBotAuthorEmail: this.config.gitBotAuthorEmail,
172
209
  });
210
+ ensuredSettingsWorkspaces.add(key);
173
211
  }
174
212
  finally {
175
213
  try {