canopycms 0.5.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.
- package/CHANGELOG.md +185 -46
- package/dist/api/branch-review.d.ts +11 -1
- package/dist/api/branch-review.js +96 -18
- package/dist/api/branch-status.d.ts +26 -2
- package/dist/api/branch-status.js +38 -2
- package/dist/api/branch-withdraw.js +4 -7
- package/dist/api/branch.d.ts +2 -0
- package/dist/api/client.d.ts +3 -3
- package/dist/api/client.js +4 -4
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/comments.js +4 -0
- package/dist/api/content.d.ts +2 -2
- package/dist/api/github-sync.d.ts +4 -0
- package/dist/api/github-sync.js +38 -6
- package/dist/api/pr-comment-sync.d.ts +18 -0
- package/dist/api/pr-comment-sync.js +77 -0
- package/dist/api/request-deadline.d.ts +15 -7
- package/dist/api/request-deadline.js +15 -7
- package/dist/api/schema.d.ts +16 -16
- package/dist/authorization/review-role.d.ts +33 -0
- package/dist/authorization/review-role.js +17 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/cli/cli.js +704 -131
- package/dist/cli/generate-ai-content.js +524 -59
- package/dist/cli/github-app-output.d.ts +34 -0
- package/dist/cli/github-app-output.js +85 -0
- package/dist/cli/init-github-app.d.ts +18 -0
- package/dist/cli/init-github-app.js +66 -58
- package/dist/cli/init.js +15 -2
- package/dist/cli/template-files/cms-stack.ts.template +3 -0
- package/dist/cli/templates.js +5 -1
- package/dist/comment-store.d.ts +5 -0
- package/dist/comment-store.js +24 -0
- package/dist/config/helpers.js +1 -3
- package/dist/config/schemas/collection.d.ts +2 -2
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -1
- package/dist/config/schemas/url.d.ts +6 -0
- package/dist/config/schemas/url.js +16 -0
- package/dist/config/types.d.ts +12 -1
- package/dist/content-id-index.d.ts +9 -0
- package/dist/content-id-index.js +31 -0
- package/dist/editor/BranchManager.d.ts +3 -0
- package/dist/editor/BranchManager.js +75 -44
- package/dist/editor/CanopyEditor.js +2 -2
- package/dist/editor/CanopyEditorPage.js +3 -4
- package/dist/editor/Editor.js +47 -8
- package/dist/editor/branch-lock.d.ts +7 -0
- package/dist/editor/branch-lock.js +9 -2
- package/dist/editor/client-reference-resolver.d.ts +5 -3
- package/dist/editor/client-reference-resolver.js +10 -5
- package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
- package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
- package/dist/editor/components/EditorHeader.d.ts +10 -4
- package/dist/editor/components/EditorHeader.js +108 -28
- package/dist/editor/components/EntryHeader.d.ts +5 -1
- package/dist/editor/components/EntryHeader.js +3 -1
- package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
- package/dist/editor/components/ReasonedMenuItem.js +2 -2
- package/dist/editor/components/ReviewDialogs.d.ts +19 -0
- package/dist/editor/components/ReviewDialogs.js +41 -0
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/fields/MarkdownField.js +1 -1
- package/dist/editor/hooks/create-branch-request.d.ts +6 -2
- package/dist/editor/hooks/create-branch-request.js +7 -2
- package/dist/editor/hooks/useBranchManager.d.ts +7 -2
- package/dist/editor/hooks/useBranchManager.js +252 -37
- package/dist/editor-url.d.ts +20 -0
- package/dist/editor-url.js +71 -0
- package/dist/git-manager.js +10 -6
- package/dist/github-service.d.ts +2 -2
- package/dist/github-service.js +4 -2
- package/dist/pr-comments.d.ts +33 -0
- package/dist/pr-comments.js +41 -0
- package/dist/schema/schema-store.d.ts +8 -8
- package/dist/services.d.ts +2 -17
- package/dist/services.js +38 -1
- package/dist/settings-workspace.d.ts +21 -7
- package/dist/settings-workspace.js +91 -16
- package/dist/submission-attribution.d.ts +38 -1
- package/dist/submission-attribution.js +197 -18
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/content-serialize.js +7 -6
- package/dist/utils/markdown-body-splice.d.ts +15 -10
- package/dist/utils/markdown-body-splice.js +71 -28
- package/dist/utils/markdown-export-options.d.ts +13 -0
- package/dist/utils/markdown-export-options.js +13 -0
- package/dist/utils/markdown-restyle.d.ts +58 -0
- package/dist/utils/markdown-restyle.js +482 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/bundle-header.d.ts +13 -9
- package/dist/worker/bundle-header.js +17 -11
- package/dist/worker/git-process.d.ts +2 -2
- package/dist/worker/github-gateway-http.js +5 -4
- package/dist/worker/github-gateway-wire.d.ts +3 -0
- package/dist/worker/github-gateway-wire.js +1 -0
- package/dist/worker/github-gateway.d.ts +1 -1
- package/dist/worker/github-gateway.js +2 -2
- package/dist/worker/github-transfer.js +72 -2
- package/dist/worker/task-runner.js +53 -3
- package/package.json +2 -2
- package/dist/editor/fields/markdown-export-options.d.ts +0 -2
- 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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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?: "
|
|
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?: "
|
|
171
|
+
format?: "md" | "mdx" | "json" | "yaml" | undefined;
|
|
172
172
|
label?: string | undefined;
|
|
173
173
|
maxItems?: number | undefined;
|
|
174
174
|
schema?: string | undefined;
|
package/dist/services.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
36
|
-
* lock
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
29
|
-
*
|
|
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(
|
|
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 `
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
205
|
-
* lock
|
|
206
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|