canopycms 0.0.67 → 0.0.68-int.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +21 -9
- package/dist/api/admin.js +18 -2
- package/dist/api/branch-status.js +19 -4
- package/dist/api/branch.js +11 -11
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +34 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- 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/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- 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 +8 -3
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/cli.js +1070 -388
- package/dist/cli/generate-ai-content.js +537 -150
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +21 -11
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +6 -0
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/schemas/config.d.ts +11 -3
- package/dist/config/schemas/config.js +5 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/url.d.ts +2 -0
- package/dist/config/schemas/url.js +7 -0
- package/dist/config/types.d.ts +28 -4
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.js +6 -5
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +45 -7
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/editor-utils.d.ts +17 -15
- package/dist/editor/editor-utils.js +43 -36
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useCommentSystem.js +5 -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/editor/preview-bridge.js +4 -1
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +79 -5
- package/dist/git-manager.js +308 -18
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +35 -13
- 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/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -1
- package/dist/paths/branch.js +15 -2
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +48 -22
- package/dist/schema/schema-store.js +66 -22
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.js +42 -4
- 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.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- 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 +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.js +23 -2
- package/dist/worker/git-sync.d.ts +8 -2
- package/dist/worker/git-sync.js +138 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +83 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SCHEMA_CACHE_FILE } from '../branch-schema-cache.js';
|
|
2
|
+
import { CANOPY_META_DIR, isCanopyInternalPath } from '../utils/git.js';
|
|
3
|
+
/**
|
|
4
|
+
* How the sync loop treats canopycms's own state (`.canopy-meta/`) in a branch clone. It is
|
|
5
|
+
* never content, so untracked state never makes a clone "dirty" for sync, but git refuses some
|
|
6
|
+
* operations over it while the adopter's repo tracks it. Only the adopter can stop that; once
|
|
7
|
+
* they have, the base clone follows on its next fast-forward ({@link untrackInIndex}), and a
|
|
8
|
+
* branch clone with modified tracked state needs an operator.
|
|
9
|
+
*/
|
|
10
|
+
/** The fix an operator applies when an adopter repo tracks `.canopy-meta/`. */
|
|
11
|
+
export const TRACKED_CANOPY_STATE_FIX = 'run `git rm -r --cached .canopy-meta` in the site repo, add `.canopy-meta/` to its .gitignore, and commit';
|
|
12
|
+
/** Paths listed in worker-status.json per category; the admin panel shows them in a tooltip. */
|
|
13
|
+
export const MAX_REPORTED_PATHS = 10;
|
|
14
|
+
/** The schema cache's location in a clone before it moved under `.git/` (branch-schema-cache.ts). */
|
|
15
|
+
const RETIRED_SCHEMA_CACHE_PATH = `${CANOPY_META_DIR}/${SCHEMA_CACHE_FILE}`;
|
|
16
|
+
export function isUntracked(file) {
|
|
17
|
+
return file.index === '?' && file.working_dir === '?';
|
|
18
|
+
}
|
|
19
|
+
/** NUL-separated git output, which is never quoted, unlike `core.quotePath` line output. */
|
|
20
|
+
function splitNul(out) {
|
|
21
|
+
return out.split('\0').filter((entry) => entry.length > 0);
|
|
22
|
+
}
|
|
23
|
+
/** Files under `.canopy-meta/` that the clone's index tracks. */
|
|
24
|
+
export async function listTrackedCanopyState(git) {
|
|
25
|
+
return splitNul(await git.raw(['ls-files', '-z', '--', CANOPY_META_DIR]));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Discard local changes to the schema cache's retired in-tree copy, so a clone of a repo that
|
|
29
|
+
* committed it can sync again. Nothing reads or writes that path in a clone any more, so its
|
|
30
|
+
* content is dead; but while it differs from HEAD, `git rebase` refuses to start and a
|
|
31
|
+
* fast-forward that touches it (including the adopter's own commit untracking it) refuses too.
|
|
32
|
+
* An untracked copy blocks neither and a newly staged one has nothing in HEAD to restore, so
|
|
33
|
+
* both are left alone. Returns whether it restored anything; the caller re-reads status if so.
|
|
34
|
+
*/
|
|
35
|
+
export async function restoreRetiredSchemaCache(git, status) {
|
|
36
|
+
const entry = status.files.find((f) => f.path === RETIRED_SCHEMA_CACHE_PATH);
|
|
37
|
+
if (!entry || entry.index === '?' || entry.index === 'A')
|
|
38
|
+
return false;
|
|
39
|
+
await git.raw(['checkout', 'HEAD', '--', RETIRED_SCHEMA_CACHE_PATH]);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The changed tracked `.canopy-meta/` files in `status`: what git refuses to rebase over, and
|
|
44
|
+
* what a fast-forward refuses to overwrite where it touches them.
|
|
45
|
+
*/
|
|
46
|
+
export function trackedCanopyStateChanges(status) {
|
|
47
|
+
return status.files
|
|
48
|
+
.filter((f) => isCanopyInternalPath(f.path) && !isUntracked(f))
|
|
49
|
+
.map((f) => f.path);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Split `paths` by whether the commit `tip` still tracks them. Those it no longer tracks, the
|
|
53
|
+
* adopter has untracked upstream, so a fast-forward onto `tip` would delete them from disk unless
|
|
54
|
+
* {@link untrackInIndex} takes them out of the index first.
|
|
55
|
+
*/
|
|
56
|
+
export async function splitByUpstreamTracking(git, paths, tip) {
|
|
57
|
+
if (paths.length === 0)
|
|
58
|
+
return { droppedUpstream: [], stillTracked: [] };
|
|
59
|
+
const tracked = new Set(splitNul(await git.raw(['ls-tree', '-r', '-z', '--name-only', tip, '--', CANOPY_META_DIR])));
|
|
60
|
+
return {
|
|
61
|
+
droppedUpstream: paths.filter((p) => !tracked.has(p)),
|
|
62
|
+
stillTracked: paths.filter((p) => tracked.has(p)),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Remove `paths` from this clone's index, leaving them on disk as untracked, excluded state.
|
|
67
|
+
* Index-only, so a concurrent write to them (branch metadata, comments) is not lost. Only for a
|
|
68
|
+
* clone with no commits of its own: a rebase that replays a commit touching these paths writes
|
|
69
|
+
* that commit's bytes over them. Paths already out of the index are ignored.
|
|
70
|
+
*/
|
|
71
|
+
export async function untrackInIndex(git, paths) {
|
|
72
|
+
if (paths.length === 0)
|
|
73
|
+
return;
|
|
74
|
+
await git.raw(['rm', '--cached', '--ignore-unmatch', '-q', '--', ...paths]);
|
|
75
|
+
}
|
|
@@ -8,7 +8,8 @@ import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.
|
|
|
8
8
|
import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
9
9
|
import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
|
|
10
10
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
11
|
-
import { writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
11
|
+
import { readLastFatalError, writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
12
|
+
import { CANOPYCMS_VERSION } from '../version.js';
|
|
12
13
|
import { workerLog, workerLogWarn, workerLogError } from './log.js';
|
|
13
14
|
import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
|
|
14
15
|
import { pollMergeState, runRebaseCycle } from './rebase.js';
|
|
@@ -67,7 +68,12 @@ export class CmsWorker {
|
|
|
67
68
|
ensureStatusReport() {
|
|
68
69
|
if (!this.statusReport) {
|
|
69
70
|
const now = new Date().toISOString();
|
|
70
|
-
this.statusReport = {
|
|
71
|
+
this.statusReport = {
|
|
72
|
+
version: 1,
|
|
73
|
+
workerVersion: CANOPYCMS_VERSION,
|
|
74
|
+
startedAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
};
|
|
71
77
|
}
|
|
72
78
|
return this.statusReport;
|
|
73
79
|
}
|
|
@@ -144,6 +150,21 @@ export class CmsWorker {
|
|
|
144
150
|
workerLog('CMS Worker starting...');
|
|
145
151
|
this.ensureStatusReport();
|
|
146
152
|
await this.acquireLock();
|
|
153
|
+
// Replace the previous holder's status file now: the first sync can take
|
|
154
|
+
// minutes, and until then System health would report the old worker's
|
|
155
|
+
// version. Best-effort, like the startup-failure write below. The previous
|
|
156
|
+
// `lastFatalError` rides along in this snapshot only, so a crash loop keeps
|
|
157
|
+
// its alert between restarts while the first successful sync still clears it.
|
|
158
|
+
try {
|
|
159
|
+
const lastFatalError = await readLastFatalError(this.taskDir);
|
|
160
|
+
await writeWorkerStatus(this.taskDir, {
|
|
161
|
+
...this.ensureStatusReport(),
|
|
162
|
+
...(lastFatalError ? { lastFatalError } : {}),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
workerLogError('Failed to write worker status after acquiring the lock:', getErrorMessage(err));
|
|
167
|
+
}
|
|
147
168
|
// Everything below runs while holding the cross-host worker lock. A failure
|
|
148
169
|
// here (most notably the empty-remote guard inside ensureRemoteGit) means
|
|
149
170
|
// the process is about to exit and systemd (Restart=always) will retry —
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { simpleGit } from 'simple-git';
|
|
2
|
+
import type { BaseRefreshReport } from '../types.js';
|
|
2
3
|
import { type RebaseContext } from './rebase.js';
|
|
3
4
|
import type { WorkerContext } from './worker-context.js';
|
|
4
5
|
/**
|
|
@@ -56,10 +57,15 @@ export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'cont
|
|
|
56
57
|
* A dedicated, explicit and LOUD step rather than a side effect of the rebase
|
|
57
58
|
* loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
|
|
58
59
|
* base clone otherwise leaves no diagnosable signal, and an editor forking a
|
|
59
|
-
* new branch "from base" silently gets a stale snapshot.
|
|
60
|
+
* new branch "from base" silently gets a stale snapshot. The returned outcome
|
|
61
|
+
* goes to worker-status.json for the same reason.
|
|
60
62
|
*
|
|
61
63
|
* ff-only on purpose: this clone must stay a linear mirror of
|
|
62
64
|
* origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
|
|
63
65
|
* history) is left untouched rather than force-resolved.
|
|
66
|
+
*
|
|
67
|
+
* Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
|
|
68
|
+
* content-write lock, both try-only: the base branch is writable in dev, and
|
|
69
|
+
* a save racing the merge's working-tree update can be overwritten by it.
|
|
64
70
|
*/
|
|
65
|
-
export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<
|
|
71
|
+
export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<BaseRefreshReport>;
|
package/dist/worker/git-sync.js
CHANGED
|
@@ -3,15 +3,18 @@ import path from 'node:path';
|
|
|
3
3
|
import { simpleGit } from 'simple-git';
|
|
4
4
|
import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
5
5
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
6
|
-
import { GITHUB_TRACKING_REF_PREFIX, gitNetworkChildEnv } from '../git-manager.js';
|
|
6
|
+
import { GITHUB_TRACKING_REF_PREFIX, ensureGitExcludePattern, gitNetworkChildEnv, } from '../git-manager.js';
|
|
7
7
|
import { RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
8
|
+
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
8
9
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
9
|
-
import { isNonFastForwardRejection } from '../utils/git.js';
|
|
10
|
+
import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
|
|
11
|
+
import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
|
|
10
12
|
import { hasPendingHistoryRewrite } from './history-rewrite.js';
|
|
11
13
|
import { runRebaseCycle } from './rebase.js';
|
|
12
14
|
import { cleanupOldTasks } from '../task-queue/cms-task-queue.js';
|
|
13
15
|
import { writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
14
16
|
import { workerLog, workerLogError, workerLogWarn } from './log.js';
|
|
17
|
+
import { holdProvisionedWorkspace, releaseProvisionedWorkspace } from './provisioned-workspace.js';
|
|
15
18
|
/**
|
|
16
19
|
* [C1] Retention window for `.trash-*` branch directories left behind by the
|
|
17
20
|
* admin purge action (api/admin-branch-health.ts). Matches
|
|
@@ -306,7 +309,7 @@ export async function syncGit(ctx) {
|
|
|
306
309
|
// correctness dependency now that the fetch can't clobber refs/heads/*
|
|
307
310
|
// -- this could run before or after them just as safely.
|
|
308
311
|
await pushSettingsBranches(ctx, git, trackedNames);
|
|
309
|
-
await refreshBaseBranchWorkspace(ctx);
|
|
312
|
+
const baseRefresh = await refreshBaseBranchWorkspace(ctx);
|
|
310
313
|
const rebaseSummary = await runRebaseCycle(ctx);
|
|
311
314
|
await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
|
|
312
315
|
// [C1] Sweep branch directories the admin purge action trashed more than
|
|
@@ -323,9 +326,14 @@ export async function syncGit(ctx) {
|
|
|
323
326
|
report.lastGitSync = {
|
|
324
327
|
durationMs: Date.now() - cycleStartedAt,
|
|
325
328
|
rebased: rebaseSummary.rebased,
|
|
326
|
-
skippedDirty:
|
|
327
|
-
|
|
329
|
+
skippedDirty: baseRefresh.outcome === 'skipped-dirty'
|
|
330
|
+
? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedDirty]
|
|
331
|
+
: rebaseSummary.skippedDirty,
|
|
332
|
+
skippedLocked: baseRefresh.outcome === 'skipped-locked'
|
|
333
|
+
? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedLocked]
|
|
334
|
+
: rebaseSummary.skippedLocked,
|
|
328
335
|
failed: rebaseSummary.failed,
|
|
336
|
+
baseRefresh,
|
|
329
337
|
tracked: trackedSummary,
|
|
330
338
|
};
|
|
331
339
|
await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
@@ -393,6 +401,12 @@ export async function cleanupTrashedBranchDirs(ctx) {
|
|
|
393
401
|
}
|
|
394
402
|
return removed;
|
|
395
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Warned (base path + tracked file list) pairs, so a repo that tracks
|
|
406
|
+
* `.canopy-meta/` is called out once per worker process rather than every cycle;
|
|
407
|
+
* worker-status.json carries it every cycle regardless.
|
|
408
|
+
*/
|
|
409
|
+
const warnedTrackedCanopyState = new Set();
|
|
396
410
|
/**
|
|
397
411
|
* Fast-forward the base branch's own working-tree clone
|
|
398
412
|
* (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
|
|
@@ -401,29 +415,62 @@ export async function cleanupTrashedBranchDirs(ctx) {
|
|
|
401
415
|
* A dedicated, explicit and LOUD step rather than a side effect of the rebase
|
|
402
416
|
* loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
|
|
403
417
|
* base clone otherwise leaves no diagnosable signal, and an editor forking a
|
|
404
|
-
* new branch "from base" silently gets a stale snapshot.
|
|
418
|
+
* new branch "from base" silently gets a stale snapshot. The returned outcome
|
|
419
|
+
* goes to worker-status.json for the same reason.
|
|
405
420
|
*
|
|
406
421
|
* ff-only on purpose: this clone must stay a linear mirror of
|
|
407
422
|
* origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
|
|
408
423
|
* history) is left untouched rather than force-resolved.
|
|
424
|
+
*
|
|
425
|
+
* Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
|
|
426
|
+
* content-write lock, both try-only: the base branch is writable in dev, and
|
|
427
|
+
* a save racing the merge's working-tree update can be overwritten by it.
|
|
409
428
|
*/
|
|
410
429
|
export async function refreshBaseBranchWorkspace(ctx) {
|
|
411
430
|
// Sanitized name for the workspace directory (a base branch containing
|
|
412
431
|
// e.g. '/' would otherwise stat a wrong nested path here forever).
|
|
413
432
|
const basePath = path.join(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
|
|
414
|
-
|
|
433
|
+
let trackedCanopyMeta;
|
|
434
|
+
let releaseProvisioning;
|
|
435
|
+
let releaseContentLock;
|
|
415
436
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
gitDirStat = null;
|
|
437
|
+
// Held to the end, so no git step below races a clone of this directory.
|
|
438
|
+
const hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
|
|
439
|
+
if (hold.kind === 'locked') {
|
|
440
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): provisioning lock held elsewhere, skipping refresh`);
|
|
441
|
+
return { outcome: 'skipped-locked' };
|
|
422
442
|
}
|
|
423
|
-
if (
|
|
443
|
+
if (hold.kind === 'not-provisioned') {
|
|
424
444
|
workerLog(`Base branch workspace (${ctx.baseBranch}): not yet provisioned, skipping refresh`);
|
|
425
|
-
return;
|
|
445
|
+
return { outcome: 'skipped-not-provisioned' };
|
|
426
446
|
}
|
|
447
|
+
releaseProvisioning = hold.release;
|
|
448
|
+
let contentLockCompromised = false;
|
|
449
|
+
try {
|
|
450
|
+
releaseContentLock = await tryAcquireContentWriteLock(basePath, (lockErr) => {
|
|
451
|
+
contentLockCompromised = true;
|
|
452
|
+
workerLogWarn(`Base branch workspace (${ctx.baseBranch}): content-write lock compromised mid-refresh: ${getErrorMessage(lockErr)}`);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
catch (lockErr) {
|
|
456
|
+
if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
|
|
457
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): content write in progress, skipping refresh`);
|
|
458
|
+
return { outcome: 'skipped-locked' };
|
|
459
|
+
}
|
|
460
|
+
throw lockErr;
|
|
461
|
+
}
|
|
462
|
+
// Checked before each destructive step: a lost provisioning lock means
|
|
463
|
+
// another process may be cloning into this directory, a lost content lock
|
|
464
|
+
// that an editor save may be landing in it.
|
|
465
|
+
const lockLost = () => {
|
|
466
|
+
if (!hold.isCompromised() && !contentLockCompromised)
|
|
467
|
+
return null;
|
|
468
|
+
const lost = hold.isCompromised() ? 'provisioning' : 'content-write';
|
|
469
|
+
workerLogWarn(`Base branch workspace (${ctx.baseBranch}): ${lost} lock lost mid-refresh, stopping`);
|
|
470
|
+
return { outcome: 'skipped-locked', trackedCanopyMeta };
|
|
471
|
+
};
|
|
472
|
+
// Idempotent, and applied every cycle so any clone lacking it gets it.
|
|
473
|
+
await ensureGitExcludePattern(basePath, `${CANOPY_META_DIR}/`);
|
|
427
474
|
const baseGit = simpleGit({
|
|
428
475
|
baseDir: basePath,
|
|
429
476
|
// Keep git non-interactive during the merge so it never blocks on an
|
|
@@ -437,17 +484,43 @@ export async function refreshBaseBranchWorkspace(ctx) {
|
|
|
437
484
|
// Inactivity-based, so a slow-but-flowing transfer is unaffected.
|
|
438
485
|
timeout: { block: ctx.taskTimeoutMs },
|
|
439
486
|
});
|
|
487
|
+
const trackedState = await listTrackedCanopyState(baseGit);
|
|
488
|
+
if (trackedState.length > 0) {
|
|
489
|
+
trackedCanopyMeta = trackedState.slice(0, MAX_REPORTED_PATHS);
|
|
490
|
+
const warnKey = `${basePath}\0${trackedState.join('\0')}`;
|
|
491
|
+
if (!warnedTrackedCanopyState.has(warnKey)) {
|
|
492
|
+
warnedTrackedCanopyState.add(warnKey);
|
|
493
|
+
workerLogWarn(`Base branch (${ctx.baseBranch}) tracks canopycms state that must not be committed: ` +
|
|
494
|
+
`${trackedState.join(', ')}. To fix, ${TRACKED_CANOPY_STATE_FIX}.`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
let status = await baseGit.status();
|
|
498
|
+
const lostBeforeRestore = lockLost();
|
|
499
|
+
if (lostBeforeRestore)
|
|
500
|
+
return lostBeforeRestore;
|
|
501
|
+
if (await restoreRetiredSchemaCache(baseGit, status)) {
|
|
502
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): restored the retired in-tree schema cache`);
|
|
503
|
+
status = await baseGit.status();
|
|
504
|
+
}
|
|
440
505
|
// Nothing makes this clone read-only, and a direct edit here wedges every
|
|
441
506
|
// editor's view of the base branch until an operator intervenes, so a dirty
|
|
442
|
-
// tree is loud, not a quiet skip. Only TRACKED changes block the
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
507
|
+
// tree is loud, not a quiet skip. Only TRACKED content changes block the
|
|
508
|
+
// refresh: canopycms's own state never does, and neither does a stray
|
|
509
|
+
// untracked file. A modified tracked file or an untracked one that would
|
|
510
|
+
// collide with incoming content makes the --ff-only merge below refuse and
|
|
511
|
+
// report `failed`; an IGNORED file, which every .canopy-meta file is, git
|
|
512
|
+
// overwrites instead.
|
|
513
|
+
const trackedDirty = status.files
|
|
514
|
+
.filter((f) => !isUntracked(f) && !isCanopyInternalPath(f.path))
|
|
515
|
+
.map((f) => f.path);
|
|
448
516
|
if (trackedDirty.length > 0) {
|
|
449
|
-
workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.
|
|
450
|
-
return
|
|
517
|
+
workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.join(', ')}`);
|
|
518
|
+
return {
|
|
519
|
+
outcome: 'skipped-dirty',
|
|
520
|
+
dirtyFiles: trackedDirty.slice(0, MAX_REPORTED_PATHS),
|
|
521
|
+
message: `${trackedDirty.length} uncommitted tracked file(s) in the base branch workspace`,
|
|
522
|
+
trackedCanopyMeta,
|
|
523
|
+
};
|
|
451
524
|
}
|
|
452
525
|
// Raw (unsanitized) name from here on: these are git ref operations
|
|
453
526
|
// against origin/<baseBranch>, not filesystem paths, so they must use
|
|
@@ -462,12 +535,31 @@ export async function refreshBaseBranchWorkspace(ctx) {
|
|
|
462
535
|
const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
|
|
463
536
|
const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
|
|
464
537
|
if (behindCount > 0) {
|
|
538
|
+
const lostBeforeMerge = lockLost();
|
|
539
|
+
if (lostBeforeMerge)
|
|
540
|
+
return lostBeforeMerge;
|
|
541
|
+
// Untrack, in the index only, any state the tip has stopped tracking:
|
|
542
|
+
// the merge would otherwise refuse to overwrite a modified copy, or
|
|
543
|
+
// delete a clean one from disk. Safe here and not in the rebase loop,
|
|
544
|
+
// because this clone has no commits of its own to replay. State still
|
|
545
|
+
// tracked upstream is left for the merge, which refuses where upstream
|
|
546
|
+
// changed a locally modified copy.
|
|
547
|
+
const { droppedUpstream } = await splitByUpstreamTracking(baseGit, trackedState, fetchedTip);
|
|
548
|
+
if (droppedUpstream.length > 0) {
|
|
549
|
+
await untrackInIndex(baseGit, droppedUpstream);
|
|
550
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): stopped tracking ${droppedUpstream.join(', ')}, as upstream has`);
|
|
551
|
+
}
|
|
465
552
|
try {
|
|
466
553
|
await baseGit.merge(['--ff-only', fetchedTip]);
|
|
467
554
|
}
|
|
468
555
|
catch (err) {
|
|
469
556
|
workerLogError(`Base branch workspace (${ctx.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
|
|
470
|
-
return
|
|
557
|
+
return {
|
|
558
|
+
outcome: 'failed',
|
|
559
|
+
// [REDACT] Served to the browser by the admin panel.
|
|
560
|
+
message: redactCredentials(`failed to fast-forward: ${getErrorMessage(err)}`),
|
|
561
|
+
trackedCanopyMeta,
|
|
562
|
+
};
|
|
471
563
|
}
|
|
472
564
|
await invalidateBranchContentCaches(basePath);
|
|
473
565
|
}
|
|
@@ -491,8 +583,30 @@ export async function refreshBaseBranchWorkspace(ctx) {
|
|
|
491
583
|
workerLog(behindCount > 0
|
|
492
584
|
? `Base branch workspace (${ctx.baseBranch}): fast-forwarded ${behindCount} commit(s)`
|
|
493
585
|
: `Base branch workspace (${ctx.baseBranch}): up to date`);
|
|
586
|
+
if (trackedCanopyMeta && behindCount > 0) {
|
|
587
|
+
const stillTracked = await listTrackedCanopyState(baseGit);
|
|
588
|
+
trackedCanopyMeta =
|
|
589
|
+
stillTracked.length > 0 ? stillTracked.slice(0, MAX_REPORTED_PATHS) : undefined;
|
|
590
|
+
}
|
|
591
|
+
return { outcome: behindCount > 0 ? 'refreshed' : 'up-to-date', trackedCanopyMeta };
|
|
494
592
|
}
|
|
495
593
|
catch (err) {
|
|
496
594
|
workerLogError(`Base branch workspace (${ctx.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
|
|
595
|
+
// [REDACT] Served to the browser by the admin panel.
|
|
596
|
+
return {
|
|
597
|
+
outcome: 'failed',
|
|
598
|
+
message: redactCredentials(getErrorMessage(err)),
|
|
599
|
+
trackedCanopyMeta,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
if (releaseContentLock) {
|
|
604
|
+
await releaseContentLock().catch((err) => {
|
|
605
|
+
workerLogWarn(`Base branch workspace (${ctx.baseBranch}): failed to release content-write lock: ${getErrorMessage(err)}`);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
if (releaseProvisioning) {
|
|
609
|
+
await releaseProvisionedWorkspace(releaseProvisioning, ctx.sanitizedBaseBranch);
|
|
610
|
+
}
|
|
497
611
|
}
|
|
498
612
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type ProvisionedWorkspaceHold = {
|
|
2
|
+
kind: 'held';
|
|
3
|
+
release: () => Promise<void>;
|
|
4
|
+
/**
|
|
5
|
+
* Whether the lock was lost mid-hold (its marker vanished or was taken over), so another
|
|
6
|
+
* process may now be cloning into this directory. Callers check it before each destructive
|
|
7
|
+
* git step and stop rather than carry on unguarded.
|
|
8
|
+
*/
|
|
9
|
+
isCompromised: () => boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Another process holds the provisioning lock: a clone, or an admin purge or repair, is in flight. */
|
|
12
|
+
| {
|
|
13
|
+
kind: 'locked';
|
|
14
|
+
}
|
|
15
|
+
/** No clone with metadata at this path yet. */
|
|
16
|
+
| {
|
|
17
|
+
kind: 'not-provisioned';
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Take a branch workspace's provisioning lock for the sync loop, the same cross-host lock the
|
|
21
|
+
* Lambda holds while it clones the workspace (`branch-workspace.ts`), so the worker never runs git
|
|
22
|
+
* against a half-made clone. The caller holds it across its dirty check and every git step, and
|
|
23
|
+
* releases it in a `finally`.
|
|
24
|
+
*
|
|
25
|
+
* Zero retries: the worker skips and retries next cycle rather than wait, so it never blocks on
|
|
26
|
+
* the Lambda and cannot deadlock with it. The rebase nests the content-write lock inside this one;
|
|
27
|
+
* the worker takes that one try-only as well, so the nesting cannot deadlock either.
|
|
28
|
+
*
|
|
29
|
+
* Provisioned means a `.git` directory AND `branch.json`. The Lambda clones directly into the
|
|
30
|
+
* target path, so `.git` appears early, and writes `branch.json` only after the clone and its
|
|
31
|
+
* lock are done.
|
|
32
|
+
*/
|
|
33
|
+
export declare function holdProvisionedWorkspace(contentBranchesPath: string, dirName: string): Promise<ProvisionedWorkspaceHold>;
|
|
34
|
+
/** Release a hold, logging rather than throwing: the sync loop's callers never throw. */
|
|
35
|
+
export declare function releaseProvisionedWorkspace(release: () => Promise<void>, dirName: string): Promise<void>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
|
|
4
|
+
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
5
|
+
import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
|
|
6
|
+
import { workerLogWarn } from './log.js';
|
|
7
|
+
/**
|
|
8
|
+
* Take a branch workspace's provisioning lock for the sync loop, the same cross-host lock the
|
|
9
|
+
* Lambda holds while it clones the workspace (`branch-workspace.ts`), so the worker never runs git
|
|
10
|
+
* against a half-made clone. The caller holds it across its dirty check and every git step, and
|
|
11
|
+
* releases it in a `finally`.
|
|
12
|
+
*
|
|
13
|
+
* Zero retries: the worker skips and retries next cycle rather than wait, so it never blocks on
|
|
14
|
+
* the Lambda and cannot deadlock with it. The rebase nests the content-write lock inside this one;
|
|
15
|
+
* the worker takes that one try-only as well, so the nesting cannot deadlock either.
|
|
16
|
+
*
|
|
17
|
+
* Provisioned means a `.git` directory AND `branch.json`. The Lambda clones directly into the
|
|
18
|
+
* target path, so `.git` appears early, and writes `branch.json` only after the clone and its
|
|
19
|
+
* lock are done.
|
|
20
|
+
*/
|
|
21
|
+
export async function holdProvisionedWorkspace(contentBranchesPath, dirName) {
|
|
22
|
+
let release;
|
|
23
|
+
let compromised = false;
|
|
24
|
+
try {
|
|
25
|
+
release = await tryAcquireProvisioningLock(contentBranchesPath, branchProvisioningLockName(dirName), (err) => {
|
|
26
|
+
compromised = true;
|
|
27
|
+
workerLogWarn(` Provisioning lock for ${dirName} was compromised mid-hold: ${getErrorMessage(err)}`);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (isNodeError(err) && err.code === 'ELOCKED')
|
|
32
|
+
return { kind: 'locked' };
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
const root = path.join(contentBranchesPath, dirName);
|
|
36
|
+
const [gitIsDir, hasMetadata] = await Promise.all([
|
|
37
|
+
fs.stat(path.join(root, '.git')).then((stat) => stat.isDirectory(), () => false),
|
|
38
|
+
fs.stat(path.join(root, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false),
|
|
39
|
+
]);
|
|
40
|
+
if (gitIsDir && hasMetadata)
|
|
41
|
+
return { kind: 'held', release, isCompromised: () => compromised };
|
|
42
|
+
await releaseProvisionedWorkspace(release, dirName);
|
|
43
|
+
return { kind: 'not-provisioned' };
|
|
44
|
+
}
|
|
45
|
+
/** Release a hold, logging rather than throwing: the sync loop's callers never throw. */
|
|
46
|
+
export async function releaseProvisionedWorkspace(release, dirName) {
|
|
47
|
+
await release().catch((err) => {
|
|
48
|
+
workerLogWarn(` Failed to release the provisioning lock for ${dirName}: ${getErrorMessage(err)}`);
|
|
49
|
+
});
|
|
50
|
+
}
|
package/dist/worker/rebase.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
20
20
|
* but still need their PR's resolution polled, and this loop is the only thing
|
|
21
21
|
* that walks every branch workspace.
|
|
22
22
|
*/
|
|
23
|
-
export type RebaseContext = Pick<WorkerContext, 'githubOwner' | 'githubRepo' | 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'octokit' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting'>;
|
|
23
|
+
export type RebaseContext = Pick<WorkerContext, 'githubOwner' | 'githubRepo' | 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'octokit' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch'>;
|
|
24
24
|
/**
|
|
25
25
|
* Per-cycle outcome of `rebaseActiveBranches()`, folded by `syncGit()` into the
|
|
26
26
|
* worker's self-reported status (`WorkerStatusReport.lastGitSync`, see
|