canopycms 0.0.67 → 0.0.68-int.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +8 -8
- package/dist/api/branch-status.js +6 -2
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +35 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +38 -39
- package/dist/api/entries.js +6 -8
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/request-url.d.ts +8 -0
- package/dist/api/request-url.js +15 -0
- package/dist/api/resolve-references.js +2 -2
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +2 -2
- package/dist/cli/cli.js +285 -168
- package/dist/cli/generate-ai-content.js +190 -109
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +15 -10
- package/dist/cli/sync.js +15 -13
- package/dist/config/schemas/config.d.ts +6 -3
- package/dist/config/schemas/config.js +3 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/types.d.ts +11 -2
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +2 -2
- package/dist/content-listing.js +3 -3
- package/dist/content-reader.js +4 -4
- package/dist/content-store.d.ts +2 -0
- package/dist/content-store.js +2 -1
- package/dist/content-tree.js +1 -1
- package/dist/context.d.ts +16 -7
- package/dist/context.js +116 -67
- package/dist/editor/Editor.js +4 -4
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +37 -6
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/entry-schema-registry.js +2 -1
- package/dist/git-manager.d.ts +20 -0
- package/dist/git-manager.js +50 -4
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +24 -9
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/index.d.ts +1 -1
- package/dist/paths/index.js +1 -1
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +3 -3
- package/dist/reference-resolver.js +3 -2
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/services.d.ts +12 -5
- package/dist/services.js +56 -56
- package/dist/settings-workspace.js +36 -0
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.js +7 -8
- package/dist/types.d.ts +23 -2
- package/dist/utils/content-write-lock.d.ts +3 -7
- package/dist/utils/content-write-lock.js +6 -4
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +15 -0
- package/dist/utils/url-prefix.js +26 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/git-sync.d.ts +4 -2
- package/dist/worker/git-sync.js +111 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.js +75 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -102,6 +102,24 @@ export interface BranchContext extends BranchPaths {
|
|
|
102
102
|
export interface BranchContextWithSchema extends BranchContext {
|
|
103
103
|
flatSchema: import('./config/index.js').FlatSchemaItem[];
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Outcome of the worker's per-cycle fast-forward of the base branch's own clone
|
|
107
|
+
* (worker/git-sync.ts's `refreshBaseBranchWorkspace`); the rebase loop skips
|
|
108
|
+
* that clone.
|
|
109
|
+
*/
|
|
110
|
+
export interface BaseRefreshReport {
|
|
111
|
+
outcome: 'refreshed' | 'up-to-date' | 'skipped-dirty' | 'skipped-locked' | 'skipped-not-provisioned' | 'failed';
|
|
112
|
+
/** Tracked files blocking the refresh, at most 10; set when `skipped-dirty`. */
|
|
113
|
+
dirtyFiles?: string[];
|
|
114
|
+
/** What went wrong, credential-redacted; set when `failed` or `skipped-dirty`. */
|
|
115
|
+
message?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Files under `.canopy-meta/` the adopter's repo tracks, at most 10. canopycms
|
|
118
|
+
* rewrites that state per branch, so tracking it is an adopter misconfiguration
|
|
119
|
+
* the panel surfaces with its fix, whatever the outcome.
|
|
120
|
+
*/
|
|
121
|
+
trackedCanopyMeta?: string[];
|
|
122
|
+
}
|
|
105
123
|
/**
|
|
106
124
|
* Wire shape of the worker's self-reported status file (worker-status.json, under
|
|
107
125
|
* the task queue dir), written by the CmsWorker daemon. Read-only here: GET
|
|
@@ -123,8 +141,9 @@ export interface WorkerStatusReport {
|
|
|
123
141
|
rebased: string[];
|
|
124
142
|
skippedDirty: string[];
|
|
125
143
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
144
|
+
* Branches skipped because another process held a lock the worker
|
|
145
|
+
* try-acquires: the [SYNC-C1] content-write lock (utils/content-write-lock.ts)
|
|
146
|
+
* or the provisioning lock (worker/provisioned-workspace.ts). The worker
|
|
128
147
|
* yields and retries next cycle. Optional for the same reason as `tracked`
|
|
129
148
|
* below, and readers must tolerate its absence.
|
|
130
149
|
*/
|
|
@@ -133,6 +152,8 @@ export interface WorkerStatusReport {
|
|
|
133
152
|
branch: string;
|
|
134
153
|
error: string;
|
|
135
154
|
}[];
|
|
155
|
+
/** Optional for the same reason as `tracked` below. */
|
|
156
|
+
baseRefresh?: BaseRefreshReport;
|
|
136
157
|
/**
|
|
137
158
|
* Outcome of reconciling remote.git's `refs/heads/*` against GitHub's fetched
|
|
138
159
|
* tips, non-destructively (worker/git-sync.ts's `reconcileTrackedBranches`).
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
*
|
|
22
22
|
* The marker lives under `{branchRoot}/.canopy-meta` and the lock anchors on that marker path like
|
|
23
23
|
* every other lock (see provisioning-lock.ts), so it can never alias the branch's provisioning
|
|
24
|
-
* lock
|
|
25
|
-
*
|
|
26
|
-
* (`ensureGitExclude`), so the lock directory cannot dirty the tree or be
|
|
24
|
+
* lock. The worker's rebase holds both, provisioning outside this one, and takes each try-only,
|
|
25
|
+
* so they cannot deadlock. `.canopy-meta/` is git-excluded in every branch clone
|
|
26
|
+
* (`ensureGitExclude`), so the lock directory cannot dirty the tree or be staged.
|
|
27
27
|
*
|
|
28
28
|
* Mutual exclusion is not proven: on EFS a stale cached mtime lets a waiter take over a live lock,
|
|
29
29
|
* leaving two unsynchronized writers -- the unlocked behaviour, so still a strict improvement.
|
|
@@ -59,10 +59,6 @@ export declare const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
|
|
|
59
59
|
export declare class ContentWriteLockBusyError extends Error {
|
|
60
60
|
constructor(message?: string);
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Acquire the branch's content-write lock WITHOUT waiting, for the worker's rebase loop, which
|
|
64
|
-
* skips the branch and retries next cycle. Throws with `code === 'ELOCKED'` on a live holder.
|
|
65
|
-
*/
|
|
66
62
|
export declare function tryAcquireContentWriteLock(branchRoot: string, onCompromised?: OnLockCompromised): Promise<() => Promise<void>>;
|
|
67
63
|
/** Run `fn` holding the branch's content-write lock, releasing in a `finally` so a throw cannot
|
|
68
64
|
* strand it. */
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
*
|
|
22
22
|
* The marker lives under `{branchRoot}/.canopy-meta` and the lock anchors on that marker path like
|
|
23
23
|
* every other lock (see provisioning-lock.ts), so it can never alias the branch's provisioning
|
|
24
|
-
* lock
|
|
25
|
-
*
|
|
26
|
-
* (`ensureGitExclude`), so the lock directory cannot dirty the tree or be
|
|
24
|
+
* lock. The worker's rebase holds both, provisioning outside this one, and takes each try-only,
|
|
25
|
+
* so they cannot deadlock. `.canopy-meta/` is git-excluded in every branch clone
|
|
26
|
+
* (`ensureGitExclude`), so the lock directory cannot dirty the tree or be staged.
|
|
27
27
|
*
|
|
28
28
|
* Mutual exclusion is not proven: on EFS a stale cached mtime lets a waiter take over a live lock,
|
|
29
29
|
* leaving two unsynchronized writers -- the unlocked behaviour, so still a strict improvement.
|
|
@@ -81,8 +81,10 @@ const sleep = (ms) => new Promise((resolve) => {
|
|
|
81
81
|
* Acquire the branch's content-write lock WITHOUT waiting, for the worker's rebase loop, which
|
|
82
82
|
* skips the branch and retries next cycle. Throws with `code === 'ELOCKED'` on a live holder.
|
|
83
83
|
*/
|
|
84
|
+
/** Below provisioning's, so a crashed worker blocks saves for at most 30s (docs/concurrency.md). */
|
|
85
|
+
const CONTENT_WRITE_LOCK_STALE_MS = 30_000;
|
|
84
86
|
export function tryAcquireContentWriteLock(branchRoot, onCompromised) {
|
|
85
|
-
return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
|
|
87
|
+
return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS);
|
|
86
88
|
}
|
|
87
89
|
/**
|
|
88
90
|
* Acquire the branch's content-write lock with a short bounded wait.
|
package/dist/utils/debug.d.ts
CHANGED
|
@@ -26,8 +26,16 @@ export declare class DebugLogger {
|
|
|
26
26
|
info(category: string, message: string, data?: unknown): void;
|
|
27
27
|
warn(category: string, message: string, data?: unknown): void;
|
|
28
28
|
error(category: string, message: string, data?: unknown): void;
|
|
29
|
+
/**
|
|
30
|
+
* Keyed by label alone, so two overlapping timers with one label overwrite each other;
|
|
31
|
+
* anything that can run concurrently uses {@link timed}.
|
|
32
|
+
*/
|
|
29
33
|
time(label: string): void;
|
|
30
34
|
timeEnd(category: string, label: string): number | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
|
|
37
|
+
* call, so concurrent spans with the same label on one logger never overwrite each other.
|
|
38
|
+
*/
|
|
31
39
|
timed<T>(category: string, label: string, fn: () => Promise<T>): Promise<T>;
|
|
32
40
|
}
|
|
33
41
|
export declare function createDebugLogger(options?: DebugOptions): DebugLogger;
|
package/dist/utils/debug.js
CHANGED
|
@@ -55,6 +55,10 @@ export class DebugLogger {
|
|
|
55
55
|
throw new Error(errorMsg);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Keyed by label alone, so two overlapping timers with one label overwrite each other;
|
|
60
|
+
* anything that can run concurrently uses {@link timed}.
|
|
61
|
+
*/
|
|
58
62
|
time(label) {
|
|
59
63
|
this.timers.set(label, Date.now());
|
|
60
64
|
}
|
|
@@ -69,13 +73,17 @@ export class DebugLogger {
|
|
|
69
73
|
this.debug(category, `${label} completed`, { durationMs: duration });
|
|
70
74
|
return duration;
|
|
71
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
|
|
78
|
+
* call, so concurrent spans with the same label on one logger never overwrite each other.
|
|
79
|
+
*/
|
|
72
80
|
async timed(category, label, fn) {
|
|
73
|
-
|
|
81
|
+
const start = Date.now();
|
|
74
82
|
try {
|
|
75
83
|
return await fn();
|
|
76
84
|
}
|
|
77
85
|
finally {
|
|
78
|
-
this.
|
|
86
|
+
this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
}
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SimpleGit } from 'simple-git';
|
|
1
2
|
import type { OperatingMode } from '../operating-mode/index.js';
|
|
2
3
|
/**
|
|
3
4
|
* Whether a git remote URL points at a network location rather than a local filesystem path. Only
|
|
@@ -39,6 +40,15 @@ export declare function isNonFastForwardRejection(message: string): boolean;
|
|
|
39
40
|
* surfacing as the permanent, human-actionable state it is. Same locale caveat as above.
|
|
40
41
|
*/
|
|
41
42
|
export declare function isStaleLeaseRejection(message: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The workflow file named by GitHub's refusal of a push that would add workflow content the
|
|
45
|
+
* credential lacks the workflows permission for, or null if the message is not that refusal.
|
|
46
|
+
*
|
|
47
|
+
* GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
|
|
48
|
+
* rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
|
|
49
|
+
* worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
|
|
50
|
+
*/
|
|
51
|
+
export declare function workflowPushRefusalFile(message: string): string | null;
|
|
42
52
|
/**
|
|
43
53
|
* Whether a `git fetch <remote> <branch>` failure is specifically "that ref doesn't exist on the
|
|
44
54
|
* remote" -- the ONLY benign fetch outcome, meaning the branch has never been pushed.
|
|
@@ -50,6 +60,24 @@ export declare function isStaleLeaseRejection(message: string): boolean;
|
|
|
50
60
|
* caveat as the predicates above.
|
|
51
61
|
*/
|
|
52
62
|
export declare function isMissingRemoteRefFailure(message: string): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* The directory, at every workspace root, holding canopycms's own per-workspace state: branch
|
|
65
|
+
* metadata, comments, generation markers and lock markers. None of it is content, so it never
|
|
66
|
+
* belongs in a commit.
|
|
67
|
+
*/
|
|
68
|
+
export declare const CANOPY_META_DIR = ".canopy-meta";
|
|
69
|
+
/**
|
|
70
|
+
* Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
|
|
71
|
+
* or lies under it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isCanopyInternalPath(repoRelativePath: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Stage every working-tree change (additions, edits, deletions) except anything under
|
|
76
|
+
* {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
|
|
77
|
+
* unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
|
|
78
|
+
* the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
|
|
79
|
+
*/
|
|
80
|
+
export declare function stageAllExceptCanopyState(git: SimpleGit): Promise<void>;
|
|
53
81
|
/**
|
|
54
82
|
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
|
|
55
83
|
* backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
|
package/dist/utils/git.js
CHANGED
|
@@ -88,6 +88,21 @@ const STALE_LEASE_REASON = 'stale info';
|
|
|
88
88
|
export function isStaleLeaseRejection(message) {
|
|
89
89
|
return message.includes(REJECTED_MARKER) && message.includes(STALE_LEASE_REASON);
|
|
90
90
|
}
|
|
91
|
+
// GitHub's reason text when a push would introduce workflow content the credential may not write.
|
|
92
|
+
// It names the credential kind ("a GitHub App", "an OAuth App", ...) and then the file.
|
|
93
|
+
// Both classes stop at a newline, so the match stays on the one status line that carries it.
|
|
94
|
+
const WORKFLOW_REFUSAL_PATTERN = /refusing to allow an? [^`\n]+? to create or update workflow `([^`\n]+)`/;
|
|
95
|
+
/**
|
|
96
|
+
* The workflow file named by GitHub's refusal of a push that would add workflow content the
|
|
97
|
+
* credential lacks the workflows permission for, or null if the message is not that refusal.
|
|
98
|
+
*
|
|
99
|
+
* GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
|
|
100
|
+
* rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
|
|
101
|
+
* worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
|
|
102
|
+
*/
|
|
103
|
+
export function workflowPushRefusalFile(message) {
|
|
104
|
+
return WORKFLOW_REFUSAL_PATTERN.exec(message)?.[1] ?? null;
|
|
105
|
+
}
|
|
91
106
|
// git's message when `git fetch <remote> <branch>` names a ref the remote does not have. Both
|
|
92
107
|
// spellings occur: modern git prints the lowercase form, older versions and some transports
|
|
93
108
|
// capitalize it.
|
|
@@ -135,6 +150,29 @@ async function resolveGitDir(repoPath) {
|
|
|
135
150
|
return null;
|
|
136
151
|
}
|
|
137
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* The directory, at every workspace root, holding canopycms's own per-workspace state: branch
|
|
155
|
+
* metadata, comments, generation markers and lock markers. None of it is content, so it never
|
|
156
|
+
* belongs in a commit.
|
|
157
|
+
*/
|
|
158
|
+
export const CANOPY_META_DIR = '.canopy-meta';
|
|
159
|
+
/**
|
|
160
|
+
* Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
|
|
161
|
+
* or lies under it.
|
|
162
|
+
*/
|
|
163
|
+
export function isCanopyInternalPath(repoRelativePath) {
|
|
164
|
+
return repoRelativePath === CANOPY_META_DIR || repoRelativePath.startsWith(`${CANOPY_META_DIR}/`);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Stage every working-tree change (additions, edits, deletions) except anything under
|
|
168
|
+
* {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
|
|
169
|
+
* unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
|
|
170
|
+
* the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
|
|
171
|
+
*/
|
|
172
|
+
export async function stageAllExceptCanopyState(git) {
|
|
173
|
+
await git.add(['-A']);
|
|
174
|
+
await git.raw(['reset', '-q', '--', CANOPY_META_DIR]);
|
|
175
|
+
}
|
|
138
176
|
/**
|
|
139
177
|
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
|
|
140
178
|
* backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
* inside the refresh timer, so a throw is an uncaught exception that kills the process.
|
|
10
10
|
*/
|
|
11
11
|
export type OnLockCompromised = (err: Error) => void;
|
|
12
|
+
/**
|
|
13
|
+
* The provisioning lock's name for the branch workspace directory `dirName`, held in that
|
|
14
|
+
* directory's parent. Every party to a branch workspace's provisioning names it through this:
|
|
15
|
+
* the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
|
|
16
|
+
* while it refreshes or rebases.
|
|
17
|
+
*/
|
|
18
|
+
export declare function branchProvisioningLockName(dirName: string): string;
|
|
12
19
|
/**
|
|
13
20
|
* Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
|
|
14
21
|
* always call it in a `finally`.
|
|
@@ -27,12 +34,15 @@ export declare function acquireProvisioningLock(lockTargetDir: string, lockName:
|
|
|
27
34
|
* request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
|
|
28
35
|
* budget waits minutes for a live provisioner; an admin request must fail fast instead.
|
|
29
36
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
37
|
+
* Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
|
|
38
|
+
* over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
|
|
39
|
+
* caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
|
|
40
|
+
* before an admin purge/repair proceeds.
|
|
34
41
|
*
|
|
35
42
|
* Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
|
|
36
43
|
* translate that into a 409.
|
|
44
|
+
*
|
|
45
|
+
* @param staleMs how old the marker must look before this caller takes it over; defaults to
|
|
46
|
+
* {@link PROVISIONING_LOCK_STALE_MS}
|
|
37
47
|
*/
|
|
38
|
-
export declare function tryAcquireProvisioningLock(lockTargetDir: string, lockName: string, onCompromised?: OnLockCompromised): Promise<() => Promise<void>>;
|
|
48
|
+
export declare function tryAcquireProvisioningLock(lockTargetDir: string, lockName: string, onCompromised?: OnLockCompromised, staleMs?: number): Promise<() => Promise<void>>;
|
|
@@ -3,6 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import lockfile from 'proper-lockfile';
|
|
4
4
|
import { getErrorMessage, isNodeError } from './error.js';
|
|
5
5
|
import { canopyLogWarn } from './logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* When an acquirer may take a provisioning marker over. Staleness is read from the marker's mtime,
|
|
8
|
+
* which EFS serves from the NFS attribute cache for up to 60s, so a live holder's 15s refreshes
|
|
9
|
+
* can look 60s late; one threshold above that for every acquirer means none reaps a live hold.
|
|
10
|
+
*/
|
|
11
|
+
const PROVISIONING_LOCK_STALE_MS = 90_000;
|
|
6
12
|
/**
|
|
7
13
|
* Shared option set for both provisioning-lock variants.
|
|
8
14
|
*
|
|
@@ -16,12 +22,14 @@ import { canopyLogWarn } from './logger.js';
|
|
|
16
22
|
* share one. `realpath: false` because that anchor path does not exist before we create it
|
|
17
23
|
* (realpath would ENOENT). See docs/concurrency.md ("Anchor path matters").
|
|
18
24
|
*/
|
|
19
|
-
function provisioningLockOptions(lockPath, retries, onCompromised) {
|
|
25
|
+
function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
|
|
20
26
|
return {
|
|
21
27
|
lockfilePath: lockPath,
|
|
22
28
|
realpath: false,
|
|
23
29
|
retries,
|
|
24
|
-
stale:
|
|
30
|
+
stale: staleMs,
|
|
31
|
+
// Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
|
|
32
|
+
update: 15_000,
|
|
25
33
|
// proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
|
|
26
34
|
// an uncaught exception that kills the process. Call sites are told not to throw (see
|
|
27
35
|
// OnLockCompromised); this makes it structural rather than a convention, and also covers the
|
|
@@ -76,6 +84,15 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
|
|
|
76
84
|
}
|
|
77
85
|
};
|
|
78
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The provisioning lock's name for the branch workspace directory `dirName`, held in that
|
|
89
|
+
* directory's parent. Every party to a branch workspace's provisioning names it through this:
|
|
90
|
+
* the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
|
|
91
|
+
* while it refreshes or rebases.
|
|
92
|
+
*/
|
|
93
|
+
export function branchProvisioningLockName(dirName) {
|
|
94
|
+
return `.${dirName}.init.lock`;
|
|
95
|
+
}
|
|
79
96
|
/**
|
|
80
97
|
* Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
|
|
81
98
|
* always call it in a `finally`.
|
|
@@ -94,9 +111,9 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
|
|
|
94
111
|
// Generous, jittered retries: several processes may contend for one workspace (Lambda
|
|
95
112
|
// containers cold-starting together against one EFS root), and the holder can take seconds to
|
|
96
113
|
// init plus clone/push. `randomize` de-syncs the herd so a waiter is not perpetually colliding
|
|
97
|
-
// on the same tick.
|
|
98
|
-
//
|
|
99
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
|
|
114
|
+
// on the same tick. A live holder's lock never goes stale, because proper-lockfile refreshes it;
|
|
115
|
+
// it expires only when a process actually dies.
|
|
116
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
|
|
100
117
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
101
118
|
}
|
|
102
119
|
/**
|
|
@@ -104,17 +121,20 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
|
|
|
104
121
|
* request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
|
|
105
122
|
* budget waits minutes for a live provisioner; an admin request must fail fast instead.
|
|
106
123
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
124
|
+
* Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
|
|
125
|
+
* over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
|
|
126
|
+
* caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
|
|
127
|
+
* before an admin purge/repair proceeds.
|
|
111
128
|
*
|
|
112
129
|
* Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
|
|
113
130
|
* translate that into a 409.
|
|
131
|
+
*
|
|
132
|
+
* @param staleMs how old the marker must look before this caller takes it over; defaults to
|
|
133
|
+
* {@link PROVISIONING_LOCK_STALE_MS}
|
|
114
134
|
*/
|
|
115
|
-
export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised) {
|
|
135
|
+
export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS) {
|
|
116
136
|
await fs.mkdir(lockTargetDir, { recursive: true });
|
|
117
137
|
const lockPath = path.join(lockTargetDir, lockName);
|
|
118
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
|
|
138
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
|
|
119
139
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
120
140
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
interface PhaseTotal {
|
|
2
|
+
count: number;
|
|
3
|
+
ms: number;
|
|
4
|
+
}
|
|
5
|
+
/** Record `fn` as `phase` in the active request scope; outside one, just run it. */
|
|
6
|
+
export declare function timeRequestPhase<T>(phase: string, fn: () => Promise<T>): Promise<T>;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a request scope is open here.
|
|
9
|
+
* @internal Exported for tests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isRequestTimingScopeActive(): boolean;
|
|
12
|
+
/** Name the matched route (a pattern, never raw path segments) for the summary line. */
|
|
13
|
+
export declare function setRequestTimingRoute(route: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* The summary line's message, after the logger's timestamp/category prefix:
|
|
16
|
+
* `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
|
|
17
|
+
* @internal Exported for tests.
|
|
18
|
+
*/
|
|
19
|
+
export declare function formatRequestTimingSummary(method: string, route: string, status: number | 'error', totalMs: number, phases: ReadonlyMap<string, PhaseTotal>): string;
|
|
20
|
+
/**
|
|
21
|
+
* Run one API request inside a timing scope and log its summary line when it settles,
|
|
22
|
+
* thrown or not. `statusOf` reads the status from the result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runWithRequestTiming<T>(method: string, fn: () => Promise<T>, statusOf: (result: T) => number): Promise<T>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request phase timing for the API handler, enabled by `CANOPYCMS_DEBUG=true`.
|
|
3
|
+
*
|
|
4
|
+
* `runWithRequestTiming` opens a request scope in AsyncLocalStorage and, when the request
|
|
5
|
+
* finishes, emits ONE summary line: method, route pattern, status, total, and every phase
|
|
6
|
+
* recorded inside the scope. `timeRequestPhase` records a phase into whichever scope is
|
|
7
|
+
* active. Scopes are per async context, so concurrent requests in one process never share
|
|
8
|
+
* or overwrite a span, and a phase reached outside any request (the worker, a build, a
|
|
9
|
+
* page render) just runs its function.
|
|
10
|
+
*
|
|
11
|
+
* Disabled, `runWithRequestTiming` calls straight through without opening a scope, so each
|
|
12
|
+
* `timeRequestPhase` costs one `getStore()` that returns undefined.
|
|
13
|
+
*
|
|
14
|
+
* A phase started inside another records under the joined name (`route>settingsRoot`), so
|
|
15
|
+
* nesting stays visible and nested time is never added to its parent's siblings. A phase
|
|
16
|
+
* run several times in one request accumulates, and the line shows the count. `untimed` is
|
|
17
|
+
* the total minus the top-level phases, which run sequentially in the handler.
|
|
18
|
+
*
|
|
19
|
+
* Server-only: `node:async_hooks`.
|
|
20
|
+
*/
|
|
21
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
22
|
+
import { createDebugLogger } from './debug.js';
|
|
23
|
+
const storage = new AsyncLocalStorage();
|
|
24
|
+
const log = createDebugLogger({ prefix: 'CanopyCMS' });
|
|
25
|
+
/** Read per call, like DebugLogger, so toggling the env var needs no restart. */
|
|
26
|
+
function isRequestTimingEnabled() {
|
|
27
|
+
return process.env.CANOPYCMS_DEBUG === 'true';
|
|
28
|
+
}
|
|
29
|
+
/** Record `fn` as `phase` in the active request scope; outside one, just run it. */
|
|
30
|
+
export async function timeRequestPhase(phase, fn) {
|
|
31
|
+
const parent = storage.getStore();
|
|
32
|
+
if (!parent)
|
|
33
|
+
return fn();
|
|
34
|
+
const key = parent.path ? `${parent.path}>${phase}` : phase;
|
|
35
|
+
// Created at START so the summary lists phases in the order they began.
|
|
36
|
+
let total = parent.phases.get(key);
|
|
37
|
+
if (!total) {
|
|
38
|
+
total = { count: 0, ms: 0 };
|
|
39
|
+
parent.phases.set(key, total);
|
|
40
|
+
}
|
|
41
|
+
const start = performance.now();
|
|
42
|
+
try {
|
|
43
|
+
return await storage.run({ ...parent, path: key }, fn);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
total.count += 1;
|
|
47
|
+
total.ms += performance.now() - start;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether a request scope is open here.
|
|
52
|
+
* @internal Exported for tests.
|
|
53
|
+
*/
|
|
54
|
+
export function isRequestTimingScopeActive() {
|
|
55
|
+
return storage.getStore() !== undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Name the matched route (a pattern, never raw path segments) for the summary line. */
|
|
58
|
+
export function setRequestTimingRoute(route) {
|
|
59
|
+
const scope = storage.getStore();
|
|
60
|
+
if (scope)
|
|
61
|
+
scope.request.route = route;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The summary line's message, after the logger's timestamp/category prefix:
|
|
65
|
+
* `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
|
|
66
|
+
* @internal Exported for tests.
|
|
67
|
+
*/
|
|
68
|
+
export function formatRequestTimingSummary(method, route, status, totalMs, phases) {
|
|
69
|
+
let topLevelMs = 0;
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const [name, { count, ms }] of phases) {
|
|
72
|
+
if (!name.includes('>'))
|
|
73
|
+
topLevelMs += ms;
|
|
74
|
+
parts.push(`${name}=${Math.round(ms)}${count > 1 ? `(x${count})` : ''}`);
|
|
75
|
+
}
|
|
76
|
+
parts.push(`untimed=${Math.max(0, Math.round(totalMs - topLevelMs))}`);
|
|
77
|
+
return `${method} ${route} ${status} ${Math.round(totalMs)}ms | ${parts.join(' ')}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run one API request inside a timing scope and log its summary line when it settles,
|
|
81
|
+
* thrown or not. `statusOf` reads the status from the result.
|
|
82
|
+
*/
|
|
83
|
+
export async function runWithRequestTiming(method, fn, statusOf) {
|
|
84
|
+
if (!isRequestTimingEnabled())
|
|
85
|
+
return fn();
|
|
86
|
+
const scope = {
|
|
87
|
+
phases: new Map(),
|
|
88
|
+
path: '',
|
|
89
|
+
request: { route: '(unmatched)' },
|
|
90
|
+
};
|
|
91
|
+
const start = performance.now();
|
|
92
|
+
let status = 'error';
|
|
93
|
+
try {
|
|
94
|
+
const result = await storage.run(scope, fn);
|
|
95
|
+
status = statusOf(result);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
log.debug('timing', formatRequestTimingSummary(method, scope.request.route, status, performance.now() - start, scope.phases));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -119,3 +119,18 @@ export declare function sanitizeUnprefixedPath(url: string): string;
|
|
|
119
119
|
* neutralizing it would silently rewrite a legitimate protocol-relative CDN base into a path.
|
|
120
120
|
*/
|
|
121
121
|
export declare function joinUrlPrefix(prefix: string | undefined, path: string): string;
|
|
122
|
+
/**
|
|
123
|
+
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
124
|
+
*
|
|
125
|
+
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
126
|
+
* `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
|
|
127
|
+
* Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
|
|
128
|
+
* 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
|
|
129
|
+
* ending `.ext`.
|
|
130
|
+
*
|
|
131
|
+
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
132
|
+
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
133
|
+
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
134
|
+
* with a trailing slash" means and breaks the URL).
|
|
135
|
+
*/
|
|
136
|
+
export declare function withTrailingSlash(path: string): string;
|
package/dist/utils/url-prefix.js
CHANGED
|
@@ -156,3 +156,29 @@ export function joinUrlPrefix(prefix, path) {
|
|
|
156
156
|
: `/${trimmedPrefix}`;
|
|
157
157
|
return `${normalizedPrefix}${normalizedPath}`;
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
161
|
+
*
|
|
162
|
+
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
163
|
+
* `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
|
|
164
|
+
* Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
|
|
165
|
+
* 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
|
|
166
|
+
* ending `.ext`.
|
|
167
|
+
*
|
|
168
|
+
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
169
|
+
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
170
|
+
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
171
|
+
* with a trailing slash" means and breaks the URL).
|
|
172
|
+
*/
|
|
173
|
+
export function withTrailingSlash(path) {
|
|
174
|
+
const splitIndex = path.search(/[?#]/);
|
|
175
|
+
const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
|
|
176
|
+
const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
|
|
177
|
+
const withLeading = base.startsWith('/') ? base : `/${base}`;
|
|
178
|
+
if (withLeading === '/' || withLeading.endsWith('/'))
|
|
179
|
+
return withLeading + suffix;
|
|
180
|
+
const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
|
|
181
|
+
if (lastSegment.includes('.'))
|
|
182
|
+
return withLeading + suffix;
|
|
183
|
+
return `${withLeading}/${suffix}`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FileStatusResult, SimpleGit, StatusResult } from 'simple-git';
|
|
2
|
+
/**
|
|
3
|
+
* How the sync loop treats canopycms's own state (`.canopy-meta/`) in a branch clone. It is
|
|
4
|
+
* never content, so untracked state never makes a clone "dirty" for sync, but git refuses some
|
|
5
|
+
* operations over it while the adopter's repo tracks it. Only the adopter can stop that; once
|
|
6
|
+
* they have, the base clone follows on its next fast-forward ({@link untrackInIndex}), and a
|
|
7
|
+
* branch clone with modified tracked state needs an operator.
|
|
8
|
+
*/
|
|
9
|
+
/** The fix an operator applies when an adopter repo tracks `.canopy-meta/`. */
|
|
10
|
+
export declare const TRACKED_CANOPY_STATE_FIX = "run `git rm -r --cached .canopy-meta` in the site repo, add `.canopy-meta/` to its .gitignore, and commit";
|
|
11
|
+
/** Paths listed in worker-status.json per category; the admin panel shows them in a tooltip. */
|
|
12
|
+
export declare const MAX_REPORTED_PATHS = 10;
|
|
13
|
+
export declare function isUntracked(file: FileStatusResult): boolean;
|
|
14
|
+
/** Files under `.canopy-meta/` that the clone's index tracks. */
|
|
15
|
+
export declare function listTrackedCanopyState(git: SimpleGit): Promise<string[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Discard local changes to the schema cache's retired in-tree copy, so a clone of a repo that
|
|
18
|
+
* committed it can sync again. Nothing reads or writes that path in a clone any more, so its
|
|
19
|
+
* content is dead; but while it differs from HEAD, `git rebase` refuses to start and a
|
|
20
|
+
* fast-forward that touches it (including the adopter's own commit untracking it) refuses too.
|
|
21
|
+
* An untracked copy blocks neither and a newly staged one has nothing in HEAD to restore, so
|
|
22
|
+
* both are left alone. Returns whether it restored anything; the caller re-reads status if so.
|
|
23
|
+
*/
|
|
24
|
+
export declare function restoreRetiredSchemaCache(git: SimpleGit, status: StatusResult): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* The changed tracked `.canopy-meta/` files in `status`: what git refuses to rebase over, and
|
|
27
|
+
* what a fast-forward refuses to overwrite where it touches them.
|
|
28
|
+
*/
|
|
29
|
+
export declare function trackedCanopyStateChanges(status: StatusResult): string[];
|
|
30
|
+
/**
|
|
31
|
+
* Split `paths` by whether the commit `tip` still tracks them. Those it no longer tracks, the
|
|
32
|
+
* adopter has untracked upstream, so a fast-forward onto `tip` would delete them from disk unless
|
|
33
|
+
* {@link untrackInIndex} takes them out of the index first.
|
|
34
|
+
*/
|
|
35
|
+
export declare function splitByUpstreamTracking(git: SimpleGit, paths: string[], tip: string): Promise<{
|
|
36
|
+
droppedUpstream: string[];
|
|
37
|
+
stillTracked: string[];
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Remove `paths` from this clone's index, leaving them on disk as untracked, excluded state.
|
|
41
|
+
* Index-only, so a concurrent write to them (branch metadata, comments) is not lost. Only for a
|
|
42
|
+
* clone with no commits of its own: a rebase that replays a commit touching these paths writes
|
|
43
|
+
* that commit's bytes over them. Paths already out of the index are ignored.
|
|
44
|
+
*/
|
|
45
|
+
export declare function untrackInIndex(git: SimpleGit, paths: string[]): Promise<void>;
|