canopycms 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +174 -47
  2. package/dist/api/branch-review.d.ts +11 -1
  3. package/dist/api/branch-review.js +96 -18
  4. package/dist/api/branch-status.d.ts +26 -2
  5. package/dist/api/branch-status.js +38 -2
  6. package/dist/api/branch-withdraw.js +4 -7
  7. package/dist/api/branch.d.ts +2 -0
  8. package/dist/api/client.d.ts +3 -3
  9. package/dist/api/client.js +4 -4
  10. package/dist/api/comments.d.ts +2 -2
  11. package/dist/api/comments.js +4 -0
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/github-sync.d.ts +4 -0
  14. package/dist/api/github-sync.js +38 -6
  15. package/dist/api/pr-comment-sync.d.ts +18 -0
  16. package/dist/api/pr-comment-sync.js +77 -0
  17. package/dist/api/request-deadline.d.ts +15 -7
  18. package/dist/api/request-deadline.js +15 -7
  19. package/dist/api/schema.d.ts +16 -16
  20. package/dist/authorization/review-role.d.ts +33 -0
  21. package/dist/authorization/review-role.js +17 -0
  22. package/dist/branch-metadata-file.d.ts +3 -0
  23. package/dist/branch-metadata-file.js +1 -0
  24. package/dist/cli/cli.js +699 -130
  25. package/dist/cli/generate-ai-content.js +524 -59
  26. package/dist/cli/github-app-output.d.ts +34 -0
  27. package/dist/cli/github-app-output.js +85 -0
  28. package/dist/cli/init-github-app.d.ts +18 -0
  29. package/dist/cli/init-github-app.js +66 -58
  30. package/dist/cli/init.js +10 -1
  31. package/dist/comment-store.d.ts +5 -0
  32. package/dist/comment-store.js +24 -0
  33. package/dist/config/helpers.js +1 -3
  34. package/dist/config/schemas/collection.d.ts +2 -2
  35. package/dist/config/schemas/config.d.ts +3 -0
  36. package/dist/config/schemas/config.js +2 -1
  37. package/dist/config/schemas/url.d.ts +6 -0
  38. package/dist/config/schemas/url.js +16 -0
  39. package/dist/config/types.d.ts +12 -1
  40. package/dist/content-id-index.d.ts +9 -0
  41. package/dist/content-id-index.js +31 -0
  42. package/dist/editor/BranchManager.d.ts +3 -0
  43. package/dist/editor/BranchManager.js +75 -44
  44. package/dist/editor/CanopyEditor.js +2 -2
  45. package/dist/editor/CanopyEditorPage.js +3 -4
  46. package/dist/editor/Editor.js +47 -8
  47. package/dist/editor/branch-lock.d.ts +7 -0
  48. package/dist/editor/branch-lock.js +9 -2
  49. package/dist/editor/client-reference-resolver.d.ts +5 -3
  50. package/dist/editor/client-reference-resolver.js +10 -5
  51. package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
  52. package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
  53. package/dist/editor/components/EditorHeader.d.ts +10 -4
  54. package/dist/editor/components/EditorHeader.js +108 -28
  55. package/dist/editor/components/EntryHeader.d.ts +5 -1
  56. package/dist/editor/components/EntryHeader.js +3 -1
  57. package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
  58. package/dist/editor/components/ReasonedMenuItem.js +2 -2
  59. package/dist/editor/components/ReviewDialogs.d.ts +19 -0
  60. package/dist/editor/components/ReviewDialogs.js +41 -0
  61. package/dist/editor/copy.d.ts +4 -0
  62. package/dist/editor/copy.js +4 -0
  63. package/dist/editor/fields/MarkdownField.js +1 -1
  64. package/dist/editor/hooks/create-branch-request.d.ts +6 -2
  65. package/dist/editor/hooks/create-branch-request.js +7 -2
  66. package/dist/editor/hooks/useBranchManager.d.ts +7 -2
  67. package/dist/editor/hooks/useBranchManager.js +252 -37
  68. package/dist/editor-url.d.ts +20 -0
  69. package/dist/editor-url.js +71 -0
  70. package/dist/git-manager.js +10 -6
  71. package/dist/github-service.d.ts +2 -2
  72. package/dist/github-service.js +4 -2
  73. package/dist/pr-comments.d.ts +33 -0
  74. package/dist/pr-comments.js +41 -0
  75. package/dist/schema/schema-store.d.ts +8 -8
  76. package/dist/services.d.ts +2 -17
  77. package/dist/services.js +38 -1
  78. package/dist/settings-workspace.d.ts +21 -7
  79. package/dist/settings-workspace.js +91 -16
  80. package/dist/submission-attribution.d.ts +38 -1
  81. package/dist/submission-attribution.js +197 -18
  82. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  83. package/dist/types.d.ts +6 -0
  84. package/dist/utils/content-serialize.js +7 -6
  85. package/dist/utils/markdown-body-splice.d.ts +15 -10
  86. package/dist/utils/markdown-body-splice.js +71 -28
  87. package/dist/utils/markdown-export-options.d.ts +13 -0
  88. package/dist/utils/markdown-export-options.js +13 -0
  89. package/dist/utils/markdown-restyle.d.ts +58 -0
  90. package/dist/utils/markdown-restyle.js +482 -0
  91. package/dist/version.d.ts +1 -1
  92. package/dist/version.js +1 -1
  93. package/dist/worker/bundle-header.d.ts +13 -9
  94. package/dist/worker/bundle-header.js +17 -11
  95. package/dist/worker/git-process.d.ts +2 -2
  96. package/dist/worker/github-gateway-http.js +3 -2
  97. package/dist/worker/github-gateway-wire.d.ts +3 -0
  98. package/dist/worker/github-gateway-wire.js +1 -0
  99. package/dist/worker/github-gateway.d.ts +1 -1
  100. package/dist/worker/github-transfer.js +72 -2
  101. package/dist/worker/task-runner.js +53 -3
  102. package/package.json +2 -2
  103. package/dist/editor/fields/markdown-export-options.d.ts +0 -2
  104. package/dist/editor/fields/markdown-export-options.js +0 -8
@@ -74,14 +74,14 @@ declare const entryTypeInputSchema: z.ZodObject<{
74
74
  maxItems: z.ZodOptional<z.ZodNumber>;
75
75
  }, "strip", z.ZodTypeAny, {
76
76
  name: string;
77
- format: "mdx" | "md" | "json" | "yaml";
77
+ format: "md" | "mdx" | "json" | "yaml";
78
78
  schema: string;
79
79
  default?: boolean | undefined;
80
80
  label?: string | undefined;
81
81
  maxItems?: number | undefined;
82
82
  }, {
83
83
  name: string;
84
- format: "mdx" | "md" | "json" | "yaml";
84
+ format: "md" | "mdx" | "json" | "yaml";
85
85
  schema: string;
86
86
  default?: boolean | undefined;
87
87
  label?: string | undefined;
@@ -100,14 +100,14 @@ declare const createCollectionInputSchema: z.ZodObject<{
100
100
  maxItems: z.ZodOptional<z.ZodNumber>;
101
101
  }, "strip", z.ZodTypeAny, {
102
102
  name: string;
103
- format: "mdx" | "md" | "json" | "yaml";
103
+ format: "md" | "mdx" | "json" | "yaml";
104
104
  schema: string;
105
105
  default?: boolean | undefined;
106
106
  label?: string | undefined;
107
107
  maxItems?: number | undefined;
108
108
  }, {
109
109
  name: string;
110
- format: "mdx" | "md" | "json" | "yaml";
110
+ format: "md" | "mdx" | "json" | "yaml";
111
111
  schema: string;
112
112
  default?: boolean | undefined;
113
113
  label?: string | undefined;
@@ -117,7 +117,7 @@ declare const createCollectionInputSchema: z.ZodObject<{
117
117
  name: string;
118
118
  entries: {
119
119
  name: string;
120
- format: "mdx" | "md" | "json" | "yaml";
120
+ format: "md" | "mdx" | "json" | "yaml";
121
121
  schema: string;
122
122
  default?: boolean | undefined;
123
123
  label?: string | undefined;
@@ -129,7 +129,7 @@ declare const createCollectionInputSchema: z.ZodObject<{
129
129
  name: string;
130
130
  entries: {
131
131
  name: string;
132
- format: "mdx" | "md" | "json" | "yaml";
132
+ format: "md" | "mdx" | "json" | "yaml";
133
133
  schema: string;
134
134
  default?: boolean | undefined;
135
135
  label?: string | undefined;
@@ -162,13 +162,13 @@ declare const updateEntryTypeInputSchema: z.ZodObject<{
162
162
  maxItems: z.ZodOptional<z.ZodNumber>;
163
163
  }, "strip", z.ZodTypeAny, {
164
164
  default?: boolean | undefined;
165
- format?: "mdx" | "md" | "json" | "yaml" | undefined;
165
+ format?: "md" | "mdx" | "json" | "yaml" | undefined;
166
166
  label?: string | undefined;
167
167
  maxItems?: number | undefined;
168
168
  schema?: string | undefined;
169
169
  }, {
170
170
  default?: boolean | undefined;
171
- format?: "mdx" | "md" | "json" | "yaml" | undefined;
171
+ format?: "md" | "mdx" | "json" | "yaml" | undefined;
172
172
  label?: string | undefined;
173
173
  maxItems?: number | undefined;
174
174
  schema?: string | undefined;
@@ -1,20 +1,3 @@
1
- /**
2
- * `CanopyServices` — the long-lived, per-deployment service container, built
3
- * once by `createCanopyServices` and threaded through `ApiContext.services` to
4
- * every API handler. Holds config, the schema registry and cache, the ACL
5
- * checkers, the git-manager factory, the branch registry, the GitHub service,
6
- * and four branch-workflow operations. Not `context.ts`, which is the
7
- * per-REQUEST facade built on top of this.
8
- *
9
- * SCOPE WARNING: a service locator every api module reaches, so adding a field
10
- * is free and therefore constant, and no handler's signature reveals what it
11
- * touches. Give a new capability its own narrow interface instead.
12
- *
13
- * `createTestCanopyServices` is test-only despite living in a production
14
- * module; it reaches the published surface through a wildcard re-export.
15
- *
16
- * Module map: ./AGENTS.md.
17
- */
18
1
  import type { CanopyConfig } from './config/index.js';
19
2
  import type { EntrySchemaRegistry } from './schema/types.js';
20
3
  import type { BranchContext, BranchPaths } from './types.js';
@@ -44,6 +27,8 @@ export interface SubmitBranchResult {
44
27
  changedPaths: string[];
45
28
  /** Every recorded editor of the branch, for the PR body. */
46
29
  editors: SubmissionEditor[];
30
+ /** The entry logical path of each changed path that is an entry, for its editor link. */
31
+ entryPaths: Map<string, string>;
47
32
  /** The entries this submit stamped (stamp-on-publish.ts), for the editor's open forms. */
48
33
  stamped: StampedEntry[];
49
34
  }
package/dist/services.js CHANGED
@@ -1,3 +1,22 @@
1
+ /**
2
+ * `CanopyServices` — the long-lived, per-deployment service container, built
3
+ * once by `createCanopyServices` and threaded through `ApiContext.services` to
4
+ * every API handler. Holds config, the schema registry and cache, the ACL
5
+ * checkers, the git-manager factory, the branch registry, the GitHub service,
6
+ * and four branch-workflow operations. Not `context.ts`, which is the
7
+ * per-REQUEST facade built on top of this.
8
+ *
9
+ * SCOPE WARNING: a service locator every api module reaches, so adding a field
10
+ * is free and therefore constant, and no handler's signature reveals what it
11
+ * touches. Give a new capability its own narrow interface instead.
12
+ *
13
+ * `createTestCanopyServices` is test-only despite living in a production
14
+ * module; it reaches the published surface through a wildcard re-export.
15
+ *
16
+ * Module map: ./AGENTS.md.
17
+ */
18
+ import fs from 'node:fs/promises';
19
+ import path from 'node:path';
1
20
  import { getConfigDefaults } from './config/index.js';
2
21
  import { createCheckBranchAccess, createCheckContentAccess, createContentAccessChecker, loadPathPermissions, } from './authorization/index.js';
3
22
  import { GitManager, GitRemoteRefMissingError } from './git-manager.js';
@@ -13,12 +32,13 @@ import { baseBranchOf } from './utils/base-branch.js';
13
32
  import { detectHeadBranch, isCanopyInternalPath } from './utils/git.js';
14
33
  import { readsFromCheckout } from './build-mode.js';
15
34
  import { timeRequestPhase } from './utils/request-timing.js';
16
- import { appendTrailers, buildEditorTrailers, describeEditors, } from './submission-attribution.js';
35
+ import { appendTrailers, buildEditorTrailers, describeEditors, MAX_LISTED_ENTRIES, } from './submission-attribution.js';
17
36
  import { getBranchMetadataFileManager, recordBranchEditor } from './branch-metadata.js';
18
37
  import { readBranchMetadataFile } from './branch-metadata-file.js';
19
38
  import { getErrorMessage, redactCredentials } from './utils/error.js';
20
39
  import { withContentWriteLock } from './utils/content-write-lock.js';
21
40
  import { ContentStore } from './content-store.js';
41
+ import { entryLogicalPathOfFile } from './content-id-index.js';
22
42
  import { stampNewEntries } from './stamp-on-publish.js';
23
43
  /**
24
44
  * A per-instance active-branch detector with its own 5s TTL cache, in priority
@@ -217,6 +237,22 @@ async function _createCanopyServicesInternal(config, options) {
217
237
  return [];
218
238
  }
219
239
  };
240
+ /** The entries among `paths` that still exist, for their editor links. */
241
+ const existingEntryPaths = async (context, paths) => {
242
+ const contentRoot = services.config.contentRoot || 'content';
243
+ const entryPaths = new Map();
244
+ // Only the paths the PR body lists can carry a link.
245
+ await Promise.all([...new Set(paths)].slice(0, MAX_LISTED_ENTRIES).map(async (relativePath) => {
246
+ const entryPath = entryLogicalPathOfFile(relativePath, contentRoot);
247
+ if (!entryPath)
248
+ return;
249
+ // A deleted entry's link would open the editor on some other entry.
250
+ const stat = await fs.stat(path.join(context.branchRoot, relativePath)).catch(() => null);
251
+ if (stat?.isFile())
252
+ entryPaths.set(relativePath, entryPath);
253
+ }));
254
+ return entryPaths;
255
+ };
220
256
  const submitBranch = async (options) => {
221
257
  // Defense-in-depth: refuse to push the base branch to itself even if the
222
258
  // 'submittableBranch' guard was somehow bypassed. Prefer the recorded fork
@@ -316,6 +352,7 @@ async function _createCanopyServicesInternal(config, options) {
316
352
  return {
317
353
  changedPaths: submitted,
318
354
  editors: await describeEditors(editorIds, lookupEditor),
355
+ entryPaths: await existingEntryPaths(options.context, submitted),
319
356
  stamped,
320
357
  };
321
358
  };
@@ -21,6 +21,10 @@ export interface EnsureSettingsWorkspaceOptions {
21
21
  mode: OperatingMode;
22
22
  remoteUrl?: string;
23
23
  }
24
+ export interface SettingsWorkspaceManagerOptions {
25
+ /** @internal How long {@link SettingsWorkspaceManager} waits for another process's build. */
26
+ buildWaitMs?: number;
27
+ }
24
28
  /**
25
29
  * The settings filesystem workspace and its git operations. Settings live on an
26
30
  * orphan git branch, sharing no history with content, and unlike
@@ -29,16 +33,17 @@ export interface EnsureSettingsWorkspaceOptions {
29
33
  *
30
34
  * A first clone follows [PROV-1] (branch-provisioning.ts): it is built in a
31
35
  * staging sibling and renamed into place, so a process killed mid-clone never
32
- * leaves a half-made workspace at the settings root. Never sparse: the orphan
33
- * init's `git rm -rf .` would leave out-of-cone base files in the tree.
36
+ * leaves a half-made workspace at the settings root.
34
37
  *
35
- * Two locking layers, mirroring BranchWorkspaceManager: an in-memory promise
36
- * lock within the process, and `acquireProvisioningLock` (proper-lockfile)
37
- * across processes and hosts around the publish and every in-place init.
38
+ * Locking: an in-memory promise lock within the process; across processes and
39
+ * hosts, the build lock electing one first-clone builder (`provisionStaged`),
40
+ * then the init lock around the publish and every in-place init. Order: build,
41
+ * then init.
38
42
  */
39
43
  export declare class SettingsWorkspaceManager {
40
44
  private readonly config;
41
- constructor(config: CanopyConfig);
45
+ private readonly buildWaitMs;
46
+ constructor(config: CanopyConfig, options?: SettingsWorkspaceManagerOptions);
42
47
  ensureGitWorkspace(options: EnsureSettingsWorkspaceOptions): Promise<void>;
43
48
  private initOptions;
44
49
  /**
@@ -49,10 +54,19 @@ export declare class SettingsWorkspaceManager {
49
54
  private withInitLock;
50
55
  /** Re-init an existing workspace where it is; false when, under the lock, there is none. */
51
56
  private ensureInPlace;
57
+ /**
58
+ * A first clone, built once for all cold starts that arrive together: the build lock's holder
59
+ * builds, and the others wait for it and find the workspace published. The wait is bounded,
60
+ * because a builder killed mid-build keeps its marker live until it goes stale; past the bound
61
+ * a waiter builds its own copy, which the publish step reconciles like any race.
62
+ */
63
+ private provisionStaged;
64
+ /** The build lock's release, or `undefined` once the wait for another builder ran out. */
65
+ private claimBuild;
52
66
  /**
53
67
  * Clone and set up the settings branch in a staging sibling, then publish it under the init
54
68
  * lock, moving an interrupted first init aside to `.trash-settings-*`. A workspace someone
55
69
  * else published meanwhile wins and the staging copy is dropped.
56
70
  */
57
- private provisionStaged;
71
+ private buildAndPublish;
58
72
  }
@@ -6,7 +6,7 @@ import { createDebugLogger } from './utils/debug.js';
6
6
  import { getErrorMessage, isNodeError } from './utils/error.js';
7
7
  import { canopyLogWarn } from './utils/logger.js';
8
8
  import { ProvisionLog } from './utils/provision-log.js';
9
- import { acquireProvisioningLock } from './utils/provisioning-lock.js';
9
+ import { acquireProvisioningLock, acquireProvisioningLockWithin } from './utils/provisioning-lock.js';
10
10
  import { RESERVED_SETTINGS_BRANCH_PREFIX } from './paths/index.js';
11
11
  const log = createDebugLogger({ prefix: 'SettingsWorkspace' });
12
12
  // In-memory lock against concurrent init within one process. One lock suffices,
@@ -25,9 +25,8 @@ let settingsInitLock = null;
25
25
  * dev, a hit also skips re-seeding a deleted `.canopy-dev/remote.git`; a dev-server restart
26
26
  * re-seeds it.
27
27
  *
28
- * A hit still reads `.git/HEAD`, so a workspace removed, re-cloned onto another branch, or
29
- * caught mid-clone by another process (HEAD still on the base branch) misses and runs the full
30
- * path, guard and lock included.
28
+ * A hit still reads `.git/HEAD`, so a workspace removed or re-cloned onto another branch misses
29
+ * and runs the full path, guard and lock included.
31
30
  */
32
31
  const ensuredSettingsWorkspaces = new Set();
33
32
  function ensuredKey(options) {
@@ -58,6 +57,14 @@ async function hasLocalBranch(settingsRoot, branchName) {
58
57
  }
59
58
  const SETTINGS_INIT_LOCK_DIR = '.settings-init';
60
59
  const SETTINGS_INIT_LOCK_NAME = 'lock';
60
+ const SETTINGS_BUILD_LOCK_NAME = 'build';
61
+ /**
62
+ * How long a cold start waits for another process's settings build before building its own.
63
+ * Well under the CMS Lambda's 60 s default timeout (canopycms-cdk's
64
+ * DEFAULT_CMS_LAMBDA_TIMEOUT) with room for the fallback build, and far under
65
+ * PROVISIONING_LOCK_STALE_MS, so a builder killed mid-build costs waiters this, not 90 s.
66
+ */
67
+ const SETTINGS_BUILD_WAIT_MS = 15_000;
61
68
  /**
62
69
  * Directory the cross-process init lock is anchored on — a dedicated sibling of
63
70
  * the settings root, NOT the settings root itself.
@@ -105,9 +112,24 @@ async function settingsFilesPresent(settingsRoot) {
105
112
  * `config.lock` that fails every later git config write.
106
113
  */
107
114
  async function settingsRootState(options) {
115
+ // Listing the parent opens it, which makes NFS revalidate it (as branch-provisioning.ts's
116
+ // `inspectBlocking` relies on), so another host's publish is seen even where this client
117
+ // cached the name's absence before that publish.
118
+ const settingsRoot = path.resolve(options.settingsRoot);
119
+ let siblings;
120
+ try {
121
+ siblings = await fs.readdir(path.dirname(settingsRoot));
122
+ }
123
+ catch (err) {
124
+ if (isNodeError(err) && err.code === 'ENOENT')
125
+ return 'vacant';
126
+ throw err;
127
+ }
128
+ if (!siblings.includes(path.basename(settingsRoot)))
129
+ return 'vacant';
108
130
  let entries;
109
131
  try {
110
- entries = await fs.readdir(options.settingsRoot);
132
+ entries = await fs.readdir(settingsRoot);
111
133
  }
112
134
  catch (err) {
113
135
  if (isNodeError(err) && err.code === 'ENOENT')
@@ -137,7 +159,7 @@ async function settingsRootState(options) {
137
159
  *
138
160
  * Without it, GitManager.initializeWorkspace sees an existing .git, skips the
139
161
  * clone, and calls createOrphanSettingsBranch(branchName); for a name neither the
140
- * workspace nor its remote has, git then runs `checkout --orphan <name>` + `rm -rf .` + an empty commit.
162
+ * workspace nor its remote has, git then runs `switch --orphan <name>` + `git rm` + an empty commit.
141
163
  * Orphan branches share no history, so that is not a migration — it
142
164
  * PERMANENTLY WIPES permissions.json/groups.json with nothing to recover from.
143
165
  * The trigger is almost always deploymentName / settingsBranch /
@@ -179,7 +201,7 @@ async function assertSettingsWorkspaceIdentity(options) {
179
201
  : `Its current branch could not be read (${readError ?? 'unknown error'}), but it ` +
180
202
  `holds settings files, and this deployment resolved settings branch ` +
181
203
  `'${options.branchName}'.`) +
182
- ` Proceeding would run \`git checkout --orphan\` + \`rm -rf .\` on this workspace, ` +
204
+ ` Proceeding would run \`git switch --orphan\` + \`git rm\` on this workspace, ` +
183
205
  `which PERMANENTLY WIPES permissions.json/groups.json (orphan branches share no ` +
184
206
  `history — there is nothing to migrate from). This almost always means ` +
185
207
  `deploymentName, settingsBranch, or CANOPYCMS_DEPLOYMENT_NAME changed on a ` +
@@ -198,16 +220,17 @@ async function assertSettingsWorkspaceIdentity(options) {
198
220
  *
199
221
  * A first clone follows [PROV-1] (branch-provisioning.ts): it is built in a
200
222
  * staging sibling and renamed into place, so a process killed mid-clone never
201
- * leaves a half-made workspace at the settings root. Never sparse: the orphan
202
- * init's `git rm -rf .` would leave out-of-cone base files in the tree.
223
+ * leaves a half-made workspace at the settings root.
203
224
  *
204
- * Two locking layers, mirroring BranchWorkspaceManager: an in-memory promise
205
- * lock within the process, and `acquireProvisioningLock` (proper-lockfile)
206
- * across processes and hosts around the publish and every in-place init.
225
+ * Locking: an in-memory promise lock within the process; across processes and
226
+ * hosts, the build lock electing one first-clone builder (`provisionStaged`),
227
+ * then the init lock around the publish and every in-place init. Order: build,
228
+ * then init.
207
229
  */
208
230
  export class SettingsWorkspaceManager {
209
- constructor(config) {
231
+ constructor(config, options = {}) {
210
232
  this.config = config;
233
+ this.buildWaitMs = options.buildWaitMs ?? SETTINGS_BUILD_WAIT_MS;
211
234
  }
212
235
  async ensureGitWorkspace(options) {
213
236
  const key = ensuredKey(options);
@@ -299,12 +322,50 @@ export class SettingsWorkspaceManager {
299
322
  return true;
300
323
  });
301
324
  }
325
+ /**
326
+ * A first clone, built once for all cold starts that arrive together: the build lock's holder
327
+ * builds, and the others wait for it and find the workspace published. The wait is bounded,
328
+ * because a builder killed mid-build keeps its marker live until it goes stale; past the bound
329
+ * a waiter builds its own copy, which the publish step reconciles like any race.
330
+ */
331
+ async provisionStaged(options) {
332
+ const releaseBuild = await this.claimBuild(options);
333
+ try {
334
+ if ((await settingsRootState(options)) === 'in-use') {
335
+ // Another process published it; the guard decides whether it is this deployment's.
336
+ await assertSettingsWorkspaceIdentity(options);
337
+ return;
338
+ }
339
+ await this.buildAndPublish(options);
340
+ }
341
+ finally {
342
+ try {
343
+ await releaseBuild?.();
344
+ }
345
+ catch (err) {
346
+ log.debug('workspace', 'Failed to release settings-build lock', { err });
347
+ }
348
+ }
349
+ }
350
+ /** The build lock's release, or `undefined` once the wait for another builder ran out. */
351
+ async claimBuild(options) {
352
+ try {
353
+ return await acquireProvisioningLockWithin(settingsInitLockTarget(options.settingsRoot), SETTINGS_BUILD_LOCK_NAME, this.buildWaitMs);
354
+ }
355
+ catch (err) {
356
+ if (!isNodeError(err) || err.code !== 'ELOCKED')
357
+ throw err;
358
+ canopyLogWarn(`[canopy] Another process's settings workspace build did not finish within ` +
359
+ `${this.buildWaitMs} ms; building one here`);
360
+ return undefined;
361
+ }
362
+ }
302
363
  /**
303
364
  * Clone and set up the settings branch in a staging sibling, then publish it under the init
304
365
  * lock, moving an interrupted first init aside to `.trash-settings-*`. A workspace someone
305
366
  * else published meanwhile wins and the staging copy is dropped.
306
367
  */
307
- async provisionStaged(options) {
368
+ async buildAndPublish(options) {
308
369
  const settingsRoot = path.resolve(options.settingsRoot);
309
370
  const workspaceRoot = path.dirname(settingsRoot);
310
371
  await fs.mkdir(workspaceRoot, { recursive: true });
@@ -318,14 +379,28 @@ export class SettingsWorkspaceManager {
318
379
  });
319
380
  published = await provisionLog.step('publish', () => this.withInitLock(options, async () => {
320
381
  const state = await settingsRootState(options);
321
- if (state === 'in-use')
382
+ if (state === 'in-use') {
383
+ // The guard above may have missed a workspace this fresher read sees.
384
+ await assertSettingsWorkspaceIdentity(options);
322
385
  return false;
386
+ }
323
387
  if (state === 'interrupted') {
324
388
  const trashName = settingsTrashDirName();
325
389
  await fs.rename(settingsRoot, path.join(workspaceRoot, trashName));
326
390
  canopyLogWarn(`[canopy] Moved an unfinished settings workspace clone aside as ${trashName}`);
327
391
  }
328
- await fs.rename(stagingPath, settingsRoot);
392
+ try {
393
+ await fs.rename(stagingPath, settingsRoot);
394
+ }
395
+ catch (err) {
396
+ // The server refuses the rename when the root holds what the state read above
397
+ // missed; a fresh read decides whether that is a published workspace.
398
+ const occupied = isNodeError(err) && (err.code === 'ENOTEMPTY' || err.code === 'EEXIST');
399
+ if (!occupied || (await settingsRootState(options)) !== 'in-use')
400
+ throw err;
401
+ await assertSettingsWorkspaceIdentity(options);
402
+ return false;
403
+ }
329
404
  return true;
330
405
  }));
331
406
  }
@@ -1,5 +1,6 @@
1
1
  import type { UserSearchResult } from './auth/types.js';
2
2
  import type { CanopyUser } from './user.js';
3
+ import type { CommentThread } from './comment-store.js';
3
4
  /**
4
5
  * Who submitted (and edited) a branch, recorded in the submit commit's trailers
5
6
  * and the pull request body. The bot stays the commit author; these records are
@@ -14,6 +15,8 @@ export interface SubmissionEditor {
14
15
  name?: string;
15
16
  email?: string;
16
17
  }
18
+ /** How many changed paths the PR body lists. */
19
+ export declare const MAX_LISTED_ENTRIES = 100;
17
20
  /**
18
21
  * The PR body region canopycms owns. On update only the text between these
19
22
  * markers is replaced, so anything a human wrote around it survives.
@@ -22,6 +25,14 @@ export interface SubmissionEditor {
22
25
  export declare const PR_SECTION_START = "<!-- canopycms:submission:start -->";
23
26
  /** @internal Exported for tests. */
24
27
  export declare const PR_SECTION_END = "<!-- canopycms:submission:end -->";
28
+ /**
29
+ * The editor-comment summary, nested in the section so a comment change can replace it alone:
30
+ * the rest of the section is built from the submit (mergeCommentsBlock).
31
+ * @internal Exported for tests.
32
+ */
33
+ export declare const PR_COMMENTS_START = "<!-- canopycms:comments:start -->";
34
+ /** @internal Exported for tests. */
35
+ export declare const PR_COMMENTS_END = "<!-- canopycms:comments:end -->";
25
36
  /**
26
37
  * A display name safe for a single line of git trailer or Markdown, or undefined.
27
38
  * @internal Exported for tests.
@@ -68,8 +79,27 @@ export interface PrSectionInput {
68
79
  editors?: readonly SubmissionEditor[];
69
80
  /** Repo-relative paths changed on the branch. */
70
81
  changedPaths: readonly string[];
82
+ /** The submitted branch and the editor page URL (editor-url.ts); together they enable links. */
83
+ branch?: string;
84
+ editorUrl?: string;
85
+ /** The entry logical path of each changed path that is an entry, for its deep link. */
86
+ entryPaths?: ReadonlyMap<string, string>;
87
+ /** The branch's editor comment threads (comments.json), or null when they could not be read. */
88
+ threads?: readonly CommentThread[] | null;
71
89
  }
72
- /** The canopycms-owned PR body section, including its start and end markers. */
90
+ /**
91
+ * The editor-comment summary between its markers: the unresolved threads, oldest first, or
92
+ * that every thread is resolved, or that there are none, or that they could not be read.
93
+ * comments.json is read without validation, so an entry that is not an object is skipped.
94
+ */
95
+ export declare function buildCommentsBlock(threads: readonly CommentThread[] | null, branch?: string, editorUrl?: string): string;
96
+ /**
97
+ * The canopycms-owned PR body section, including its start and end markers. GitHub refuses a
98
+ * body over 65,536 characters and the rest of the body is a human's, so a section whose text
99
+ * apart from the comment block is over MAX_SECTION_LENGTH loses its entry links, then the
100
+ * description's end, then entries from the end of the list. The attribution lines themselves
101
+ * are never cut. The comment block, rendered now or re-rendered later, has MAX_COMMENTS_LENGTH.
102
+ */
73
103
  export declare function buildPrSection(input: PrSectionInput): string;
74
104
  /**
75
105
  * `existing` with its canopycms section replaced by `section`, keeping every
@@ -79,3 +109,10 @@ export declare function buildPrSection(input: PrSectionInput): string;
79
109
  * removed and `section` is appended after the human text.
80
110
  */
81
111
  export declare function mergePrSection(existing: string | null | undefined, section: string): string;
112
+ /**
113
+ * `existing` with the comment block inside its canopycms section replaced by `block`, or
114
+ * undefined when the section or its block is missing (a PR body written before the block
115
+ * existed, or edited by hand): the next submit writes both. Only markers inside the section
116
+ * count, and the description in it cannot spell one (neutralizeComments).
117
+ */
118
+ export declare function mergeCommentsBlock(existing: string | null | undefined, block: string): string | undefined;