canopycms 0.0.67 → 0.0.68-int.101
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 +24 -17
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +118 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- 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 +19 -1
- package/dist/api/github-sync.js +57 -10
- package/dist/api/index.d.ts +1 -1
- 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/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/file-based-auth-cache.js +7 -8
- 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.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- 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 +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- 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/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- 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 +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +15 -0
- package/dist/github-service.js +19 -1
- package/dist/http/handler.js +37 -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/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- 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/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -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 +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +64 -9
- package/dist/server.js +48 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- 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-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- 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 +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/entry-validator.js +9 -3
- 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.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- 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 +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
|
@@ -15,11 +15,11 @@ export type AuthCacheRefresher = () => Promise<void>;
|
|
|
15
15
|
* interface for the two shapes and why an App is optional.
|
|
16
16
|
*/
|
|
17
17
|
export interface CmsWorkerConfig extends GitHubAuthConfig {
|
|
18
|
-
/** Path to workspace root on EFS (e.g., /mnt/efs
|
|
18
|
+
/** Path to workspace root on EFS (e.g., /mnt/efs) */
|
|
19
19
|
workspacePath: string;
|
|
20
|
-
/** GitHub owner (e.g., '
|
|
20
|
+
/** GitHub owner (e.g., 'acme') */
|
|
21
21
|
githubOwner: string;
|
|
22
|
-
/** GitHub repo name (e.g., '
|
|
22
|
+
/** GitHub repo name (e.g., 'site') */
|
|
23
23
|
githubRepo: string;
|
|
24
24
|
/** Called periodically to update the auth metadata cache on EFS. */
|
|
25
25
|
refreshAuthCache?: AuthCacheRefresher;
|
|
@@ -203,6 +203,11 @@ export declare class CmsWorker {
|
|
|
203
203
|
* survives, so a failed scrub is never indistinguishable from a clean one.
|
|
204
204
|
*/
|
|
205
205
|
private scrubPersistedRemote;
|
|
206
|
+
/**
|
|
207
|
+
* Best-effort: without these settings remote.git still works, and a boot that
|
|
208
|
+
* cannot write its own config has a louder problem to report elsewhere.
|
|
209
|
+
*/
|
|
210
|
+
private applyRemoteGitConfig;
|
|
206
211
|
/**
|
|
207
212
|
* Ensure the remote.git bare repo exists, cloning it from GitHub on first
|
|
208
213
|
* run.
|
|
@@ -5,10 +5,12 @@ import lockfile from 'proper-lockfile';
|
|
|
5
5
|
import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
|
|
6
6
|
import { createCanopyOctokit } from '../github-service.js';
|
|
7
7
|
import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.js';
|
|
8
|
+
import { ensureRemoteGitConfig } from '../git-manager.js';
|
|
8
9
|
import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
9
10
|
import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
|
|
10
11
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
11
|
-
import { writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
12
|
+
import { readLastFatalError, writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
13
|
+
import { CANOPYCMS_VERSION } from '../version.js';
|
|
12
14
|
import { workerLog, workerLogWarn, workerLogError } from './log.js';
|
|
13
15
|
import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
|
|
14
16
|
import { pollMergeState, runRebaseCycle } from './rebase.js';
|
|
@@ -67,7 +69,12 @@ export class CmsWorker {
|
|
|
67
69
|
ensureStatusReport() {
|
|
68
70
|
if (!this.statusReport) {
|
|
69
71
|
const now = new Date().toISOString();
|
|
70
|
-
this.statusReport = {
|
|
72
|
+
this.statusReport = {
|
|
73
|
+
version: 1,
|
|
74
|
+
workerVersion: CANOPYCMS_VERSION,
|
|
75
|
+
startedAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
};
|
|
71
78
|
}
|
|
72
79
|
return this.statusReport;
|
|
73
80
|
}
|
|
@@ -144,6 +151,21 @@ export class CmsWorker {
|
|
|
144
151
|
workerLog('CMS Worker starting...');
|
|
145
152
|
this.ensureStatusReport();
|
|
146
153
|
await this.acquireLock();
|
|
154
|
+
// Replace the previous holder's status file now: the first sync can take
|
|
155
|
+
// minutes, and until then System health would report the old worker's
|
|
156
|
+
// version. Best-effort, like the startup-failure write below. The previous
|
|
157
|
+
// `lastFatalError` rides along in this snapshot only, so a crash loop keeps
|
|
158
|
+
// its alert between restarts while the first successful sync still clears it.
|
|
159
|
+
try {
|
|
160
|
+
const lastFatalError = await readLastFatalError(this.taskDir);
|
|
161
|
+
await writeWorkerStatus(this.taskDir, {
|
|
162
|
+
...this.ensureStatusReport(),
|
|
163
|
+
...(lastFatalError ? { lastFatalError } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
workerLogError('Failed to write worker status after acquiring the lock:', getErrorMessage(err));
|
|
168
|
+
}
|
|
147
169
|
// Everything below runs while holding the cross-host worker lock. A failure
|
|
148
170
|
// here (most notably the empty-remote guard inside ensureRemoteGit) means
|
|
149
171
|
// the process is about to exit and systemd (Restart=always) will retry —
|
|
@@ -411,6 +433,18 @@ export class CmsWorker {
|
|
|
411
433
|
`shared storage. Refusing to continue.`);
|
|
412
434
|
}
|
|
413
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* Best-effort: without these settings remote.git still works, and a boot that
|
|
438
|
+
* cannot write its own config has a louder problem to report elsewhere.
|
|
439
|
+
*/
|
|
440
|
+
async applyRemoteGitConfig(gitDir) {
|
|
441
|
+
try {
|
|
442
|
+
await ensureRemoteGitConfig(gitDir);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
workerLogWarn(`Could not apply remote.git config in ${gitDir}: ${getErrorMessage(err)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
414
448
|
/**
|
|
415
449
|
* Ensure the remote.git bare repo exists, cloning it from GitHub on first
|
|
416
450
|
* run.
|
|
@@ -450,6 +484,7 @@ export class CmsWorker {
|
|
|
450
484
|
// deletion is the operator's call.
|
|
451
485
|
throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
|
|
452
486
|
}
|
|
487
|
+
await this.applyRemoteGitConfig(this.remoteGitPath);
|
|
453
488
|
return; // Already exists and has the base branch
|
|
454
489
|
}
|
|
455
490
|
workerLog('Initializing remote.git from GitHub...');
|
|
@@ -468,6 +503,7 @@ export class CmsWorker {
|
|
|
468
503
|
// swallowing) if the scrub does not take.
|
|
469
504
|
await this.scrubPersistedRemote(stagingPath);
|
|
470
505
|
await this.verifyBaseBranchExists(stagingPath);
|
|
506
|
+
await this.applyRemoteGitConfig(stagingPath);
|
|
471
507
|
}
|
|
472
508
|
catch (err) {
|
|
473
509
|
workerLogError(`remote.git clone failed: ${redactCredentials(getErrorMessage(err))}`);
|
|
@@ -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
|
/**
|
|
@@ -6,7 +7,10 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
6
7
|
* slower of the worker's two poll loops (default 5 minutes, against the task
|
|
7
8
|
* queue's 5 seconds).
|
|
8
9
|
*
|
|
9
|
-
* One cycle, in order:
|
|
10
|
+
* One cycle, in order: repair what killed provisioning and deletes left under
|
|
11
|
+
* the branches root (`repairBranchDirResidue`), move sparse clones to a changed
|
|
12
|
+
* content root's cone (sparse-cone.ts), repack `remote.git` when it needs it
|
|
13
|
+
* (remote-git-maintenance.ts), fetch every GitHub branch into the tracking namespace,
|
|
10
14
|
* bring `refs/heads/*` toward it non-destructively (`reconcileTrackedBranches`),
|
|
11
15
|
* push this deployment's own settings branch, fast-forward the base branch's
|
|
12
16
|
* workspace, rebase every branch that is behind it (rebase.ts), then sweep old
|
|
@@ -14,7 +18,7 @@ import type { WorkerContext } from './worker-context.js';
|
|
|
14
18
|
*
|
|
15
19
|
* One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
|
|
16
20
|
* follow `reconcileTrackedBranches`. Branch clones fetch the base tip from
|
|
17
|
-
* `remote.git
|
|
21
|
+
* `remote.git`, and `reconcileTrackedBranches` is what advances
|
|
18
22
|
* `remote.git`'s `refs/heads/*` toward what the fetch above put in the tracking
|
|
19
23
|
* namespace; reorder them and every branch rebases onto the PREVIOUS cycle's
|
|
20
24
|
* base tip -- not corrupting, but silently a cycle behind.
|
|
@@ -39,27 +43,41 @@ export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBr
|
|
|
39
43
|
*/
|
|
40
44
|
export declare function pushSettingsBranches(ctx: GitSyncContext, git: ReturnType<typeof simpleGit>, trackedNames: ReadonlySet<string>): Promise<void>;
|
|
41
45
|
export declare function syncGit(ctx: GitSyncContext): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Repair what killed provisioning and deletes left under the branches root: quarantine residue
|
|
48
|
+
* at a branch's name (branch-provisioning.ts's `classifyFinalDir`) once it has been quiet for
|
|
49
|
+
* {@link ORPHAN_YOUTH_THRESHOLD_MS}, then sweep stale `.prov-*`, `.deleting-*` and `.repair-*`
|
|
50
|
+
* siblings and settings leftovers. Each directory is best-effort, like [SYNC-M2]. The provisioning
|
|
51
|
+
* lock is taken zero-retry, so a live provisioner or admin action wins and the directory is
|
|
52
|
+
* revisited next cycle.
|
|
53
|
+
* @internal Exported for tests; `syncGit` runs it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function repairBranchDirResidue(ctx: Pick<GitSyncContext, 'contentBranchesPath' | 'remoteGitPath'>, now?: number): Promise<void>;
|
|
42
56
|
/**
|
|
43
57
|
* [C1] Remove `.trash-*` branch directories (created by the admin purge action,
|
|
44
|
-
* api/admin-branch-health.ts
|
|
45
|
-
* {@link TRASH_RETENTION_MS}. A name
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* is unexpected and worth a human looking rather than a silent skip.
|
|
58
|
+
* api/admin-branch-health.ts, and by residue quarantine, branch-provisioning.ts)
|
|
59
|
+
* whose name-embedded stamp is older than {@link TRASH_RETENTION_MS}. A name
|
|
60
|
+
* whose trailing stamp fails to parse is left alone and logged once per cycle:
|
|
61
|
+
* both writers end every name with one, so it is worth a human looking.
|
|
49
62
|
*/
|
|
50
63
|
export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'contentBranchesPath'>): Promise<number>;
|
|
51
64
|
/**
|
|
52
65
|
* Fast-forward the base branch's own working-tree clone
|
|
53
|
-
* (content-branches/<baseBranch>) to match
|
|
66
|
+
* (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
|
|
54
67
|
* cycle, so the drift window is bounded by gitSyncInterval.
|
|
55
68
|
*
|
|
56
69
|
* A dedicated, explicit and LOUD step rather than a side effect of the rebase
|
|
57
70
|
* loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
|
|
58
71
|
* base clone otherwise leaves no diagnosable signal, and an editor forking a
|
|
59
|
-
* new branch "from base" silently gets a stale snapshot.
|
|
72
|
+
* new branch "from base" silently gets a stale snapshot. The returned outcome
|
|
73
|
+
* goes to worker-status.json for the same reason.
|
|
60
74
|
*
|
|
61
|
-
* ff-only on purpose: this clone must stay a linear mirror of
|
|
62
|
-
*
|
|
75
|
+
* ff-only on purpose: this clone must stay a linear mirror of remote.git's
|
|
76
|
+
* <baseBranch>, so a merge that isn't a fast-forward (diverged local
|
|
63
77
|
* history) is left untouched rather than force-resolved.
|
|
78
|
+
*
|
|
79
|
+
* Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
|
|
80
|
+
* content-write lock, both try-only: the base branch is writable in dev, and
|
|
81
|
+
* a save racing the merge's working-tree update can be overwritten by it.
|
|
64
82
|
*/
|
|
65
|
-
export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<
|
|
83
|
+
export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<BaseRefreshReport>;
|
package/dist/worker/git-sync.js
CHANGED
|
@@ -2,39 +2,31 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { simpleGit } from 'simple-git';
|
|
4
4
|
import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
5
|
+
import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
|
|
6
|
+
import { ORPHAN_YOUTH_THRESHOLD_MS } from '../branch-health.js';
|
|
7
|
+
import { classifyFinalDir, parseDirStamp, quarantineResidueAt, sweepProvisioningLeftovers, } from '../branch-provisioning.js';
|
|
5
8
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
6
|
-
import { GITHUB_TRACKING_REF_PREFIX, gitNetworkChildEnv } from '../git-manager.js';
|
|
9
|
+
import { GITHUB_TRACKING_REF_PREFIX, ensureGitExcludePattern, gitNetworkChildEnv, } from '../git-manager.js';
|
|
7
10
|
import { RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
|
|
11
|
+
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
12
|
+
import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
|
|
8
13
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
9
|
-
import { isNonFastForwardRejection } from '../utils/git.js';
|
|
14
|
+
import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
|
|
15
|
+
import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
|
|
10
16
|
import { hasPendingHistoryRewrite } from './history-rewrite.js';
|
|
11
17
|
import { runRebaseCycle } from './rebase.js';
|
|
12
18
|
import { cleanupOldTasks } from '../task-queue/cms-task-queue.js';
|
|
13
19
|
import { writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
14
20
|
import { workerLog, workerLogError, workerLogWarn } from './log.js';
|
|
21
|
+
import { holdProvisionedWorkspace, releaseProvisionedWorkspace } from './provisioned-workspace.js';
|
|
22
|
+
import { maintainRemoteGit } from './remote-git-maintenance.js';
|
|
23
|
+
import { reapplySparseCones } from './sparse-cone.js';
|
|
15
24
|
/**
|
|
16
25
|
* [C1] Retention window for `.trash-*` branch directories left behind by the
|
|
17
26
|
* admin purge action (api/admin-branch-health.ts). Matches
|
|
18
27
|
* cleanupOldTasks's default task retention for consistency.
|
|
19
28
|
*/
|
|
20
29
|
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
|
21
|
-
/** Matches `.trash-{dirName}-{STAMP}` names, capturing the trailing stamp. */
|
|
22
|
-
const TRASH_DIR_STAMP_RE = /-(\d{8}T\d{6}Z)$/;
|
|
23
|
-
/**
|
|
24
|
-
* Parse a purge-generated `YYYYMMDDTHHMMSSZ` stamp into a Date, or null if
|
|
25
|
-
* malformed. Age comes ONLY from this name-embedded stamp, never the dir's
|
|
26
|
-
* own mtime -- `fs.rename` preserves the original directory's mtime, so an
|
|
27
|
-
* mtime-based retention check would delete a months-stale orphan's trash on
|
|
28
|
-
* the very first cleanup pass after purge.
|
|
29
|
-
*/
|
|
30
|
-
function parseTrashStamp(stamp) {
|
|
31
|
-
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
|
|
32
|
-
if (!match)
|
|
33
|
-
return null;
|
|
34
|
-
const [, year, month, day, hour, minute, second] = match;
|
|
35
|
-
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`);
|
|
36
|
-
return Number.isNaN(date.getTime()) ? null : date;
|
|
37
|
-
}
|
|
38
30
|
/**
|
|
39
31
|
* Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
|
|
40
32
|
* remote.git to GitHub. Non-fatal: a no-op push for an up-to-date branch just
|
|
@@ -288,6 +280,27 @@ export async function syncGit(ctx) {
|
|
|
288
280
|
// mask the real error on a failed one. Failures rethrow, so scheduleLoop's
|
|
289
281
|
// per-cycle catch stays the loud path.
|
|
290
282
|
try {
|
|
283
|
+
// Best-effort and ahead of the GitHub fetch, so a GitHub outage never
|
|
284
|
+
// stalls upkeep and a failure here never costs the cycle. start() runs a
|
|
285
|
+
// cycle at once, so residue repair also runs on boot.
|
|
286
|
+
try {
|
|
287
|
+
await repairBranchDirResidue(ctx);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
workerLogWarn(`Branch directory residue repair failed: ${getErrorMessage(err)}`);
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
await reapplySparseCones(ctx);
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
workerLogWarn(`Sparse-checkout cone update failed: ${getErrorMessage(err)}`);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await maintainRemoteGit(ctx.remoteGitPath);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
workerLogWarn(`remote.git maintenance failed: ${getErrorMessage(err)}`);
|
|
303
|
+
}
|
|
291
304
|
// Direct URL (no named remote), into the GITHUB_TRACKING_REF_PREFIX
|
|
292
305
|
// remote-tracking namespace rather than refs/heads/* -- see that constant's
|
|
293
306
|
// doc comment for the destructive-fetch bug this avoids. Raw git, because
|
|
@@ -306,7 +319,7 @@ export async function syncGit(ctx) {
|
|
|
306
319
|
// correctness dependency now that the fetch can't clobber refs/heads/*
|
|
307
320
|
// -- this could run before or after them just as safely.
|
|
308
321
|
await pushSettingsBranches(ctx, git, trackedNames);
|
|
309
|
-
await refreshBaseBranchWorkspace(ctx);
|
|
322
|
+
const baseRefresh = await refreshBaseBranchWorkspace(ctx);
|
|
310
323
|
const rebaseSummary = await runRebaseCycle(ctx);
|
|
311
324
|
await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
|
|
312
325
|
// [C1] Sweep branch directories the admin purge action trashed more than
|
|
@@ -323,9 +336,14 @@ export async function syncGit(ctx) {
|
|
|
323
336
|
report.lastGitSync = {
|
|
324
337
|
durationMs: Date.now() - cycleStartedAt,
|
|
325
338
|
rebased: rebaseSummary.rebased,
|
|
326
|
-
skippedDirty:
|
|
327
|
-
|
|
339
|
+
skippedDirty: baseRefresh.outcome === 'skipped-dirty'
|
|
340
|
+
? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedDirty]
|
|
341
|
+
: rebaseSummary.skippedDirty,
|
|
342
|
+
skippedLocked: baseRefresh.outcome === 'skipped-locked'
|
|
343
|
+
? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedLocked]
|
|
344
|
+
: rebaseSummary.skippedLocked,
|
|
328
345
|
failed: rebaseSummary.failed,
|
|
346
|
+
baseRefresh,
|
|
329
347
|
tracked: trackedSummary,
|
|
330
348
|
};
|
|
331
349
|
await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
|
|
@@ -343,13 +361,87 @@ export async function syncGit(ctx) {
|
|
|
343
361
|
throw err;
|
|
344
362
|
}
|
|
345
363
|
}
|
|
364
|
+
/** Foreign repositories already reported by this process, so each is logged once. */
|
|
365
|
+
const reportedForeignDirs = new Set();
|
|
366
|
+
/**
|
|
367
|
+
* Repair what killed provisioning and deletes left under the branches root: quarantine residue
|
|
368
|
+
* at a branch's name (branch-provisioning.ts's `classifyFinalDir`) once it has been quiet for
|
|
369
|
+
* {@link ORPHAN_YOUTH_THRESHOLD_MS}, then sweep stale `.prov-*`, `.deleting-*` and `.repair-*`
|
|
370
|
+
* siblings and settings leftovers. Each directory is best-effort, like [SYNC-M2]. The provisioning
|
|
371
|
+
* lock is taken zero-retry, so a live provisioner or admin action wins and the directory is
|
|
372
|
+
* revisited next cycle.
|
|
373
|
+
* @internal Exported for tests; `syncGit` runs it.
|
|
374
|
+
*/
|
|
375
|
+
export async function repairBranchDirResidue(ctx, now = Date.now()) {
|
|
376
|
+
let entries;
|
|
377
|
+
try {
|
|
378
|
+
entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
382
|
+
return;
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
for (const entry of entries) {
|
|
386
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
387
|
+
continue;
|
|
388
|
+
try {
|
|
389
|
+
await repairOneBranchDir(ctx, entry.name, now);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
workerLogWarn(` Residue check of ${entry.name} failed: ${getErrorMessage(err)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const leftovers = await sweepProvisioningLeftovers(ctx.contentBranchesPath, path.dirname(ctx.contentBranchesPath), now);
|
|
396
|
+
for (const { name, action, detail } of leftovers) {
|
|
397
|
+
const line = ` Leftover ${name}: ${action}${detail ? ` (${detail})` : ''}`;
|
|
398
|
+
if (action === 'removed')
|
|
399
|
+
workerLog(line);
|
|
400
|
+
else if (action === 'failed')
|
|
401
|
+
workerLogWarn(line);
|
|
402
|
+
else
|
|
403
|
+
workerLogError(line);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function repairOneBranchDir(ctx, dirName, now) {
|
|
407
|
+
const dirPath = path.join(ctx.contentBranchesPath, dirName);
|
|
408
|
+
// One stat for the common case: a live (or corrupt) branch is never residue.
|
|
409
|
+
const hasMeta = await fs.access(path.join(dirPath, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
|
|
410
|
+
if (hasMeta)
|
|
411
|
+
return;
|
|
412
|
+
const state = await classifyFinalDir(dirPath, ctx.remoteGitPath);
|
|
413
|
+
if (state.kind === 'foreign' && !reportedForeignDirs.has(dirPath)) {
|
|
414
|
+
reportedForeignDirs.add(dirPath);
|
|
415
|
+
workerLogWarn(` ${dirName} holds a git repository this deployment did not create; left for an admin`);
|
|
416
|
+
}
|
|
417
|
+
if (state.kind !== 'residue')
|
|
418
|
+
return;
|
|
419
|
+
let release;
|
|
420
|
+
try {
|
|
421
|
+
release = await tryAcquireProvisioningLock(ctx.contentBranchesPath, branchProvisioningLockName(dirName));
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
if (isNodeError(err) && err.code === 'ELOCKED')
|
|
425
|
+
return;
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
await quarantineResidueAt(ctx.contentBranchesPath, dirName, {
|
|
430
|
+
minQuietMs: ORPHAN_YOUTH_THRESHOLD_MS,
|
|
431
|
+
expectedRemoteUrl: ctx.remoteGitPath,
|
|
432
|
+
now,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await releaseProvisionedWorkspace(release, dirName);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
346
439
|
/**
|
|
347
440
|
* [C1] Remove `.trash-*` branch directories (created by the admin purge action,
|
|
348
|
-
* api/admin-branch-health.ts
|
|
349
|
-
* {@link TRASH_RETENTION_MS}. A name
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* is unexpected and worth a human looking rather than a silent skip.
|
|
441
|
+
* api/admin-branch-health.ts, and by residue quarantine, branch-provisioning.ts)
|
|
442
|
+
* whose name-embedded stamp is older than {@link TRASH_RETENTION_MS}. A name
|
|
443
|
+
* whose trailing stamp fails to parse is left alone and logged once per cycle:
|
|
444
|
+
* both writers end every name with one, so it is worth a human looking.
|
|
353
445
|
*/
|
|
354
446
|
export async function cleanupTrashedBranchDirs(ctx) {
|
|
355
447
|
let entries;
|
|
@@ -367,8 +459,7 @@ export async function cleanupTrashedBranchDirs(ctx) {
|
|
|
367
459
|
for (const name of entries) {
|
|
368
460
|
if (!name.startsWith('.trash-'))
|
|
369
461
|
continue;
|
|
370
|
-
const
|
|
371
|
-
const stampDate = stampMatch ? parseTrashStamp(stampMatch[1]) : null;
|
|
462
|
+
const stampDate = parseDirStamp(name);
|
|
372
463
|
if (!stampDate) {
|
|
373
464
|
if (!loggedUnparseable) {
|
|
374
465
|
workerLog(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
|
|
@@ -393,37 +484,76 @@ export async function cleanupTrashedBranchDirs(ctx) {
|
|
|
393
484
|
}
|
|
394
485
|
return removed;
|
|
395
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Warned (base path + tracked file list) pairs, so a repo that tracks
|
|
489
|
+
* `.canopy-meta/` is called out once per worker process rather than every cycle;
|
|
490
|
+
* worker-status.json carries it every cycle regardless.
|
|
491
|
+
*/
|
|
492
|
+
const warnedTrackedCanopyState = new Set();
|
|
396
493
|
/**
|
|
397
494
|
* Fast-forward the base branch's own working-tree clone
|
|
398
|
-
* (content-branches/<baseBranch>) to match
|
|
495
|
+
* (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
|
|
399
496
|
* cycle, so the drift window is bounded by gitSyncInterval.
|
|
400
497
|
*
|
|
401
498
|
* A dedicated, explicit and LOUD step rather than a side effect of the rebase
|
|
402
499
|
* loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
|
|
403
500
|
* base clone otherwise leaves no diagnosable signal, and an editor forking a
|
|
404
|
-
* new branch "from base" silently gets a stale snapshot.
|
|
501
|
+
* new branch "from base" silently gets a stale snapshot. The returned outcome
|
|
502
|
+
* goes to worker-status.json for the same reason.
|
|
405
503
|
*
|
|
406
|
-
* ff-only on purpose: this clone must stay a linear mirror of
|
|
407
|
-
*
|
|
504
|
+
* ff-only on purpose: this clone must stay a linear mirror of remote.git's
|
|
505
|
+
* <baseBranch>, so a merge that isn't a fast-forward (diverged local
|
|
408
506
|
* history) is left untouched rather than force-resolved.
|
|
507
|
+
*
|
|
508
|
+
* Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
|
|
509
|
+
* content-write lock, both try-only: the base branch is writable in dev, and
|
|
510
|
+
* a save racing the merge's working-tree update can be overwritten by it.
|
|
409
511
|
*/
|
|
410
512
|
export async function refreshBaseBranchWorkspace(ctx) {
|
|
411
513
|
// Sanitized name for the workspace directory (a base branch containing
|
|
412
514
|
// e.g. '/' would otherwise stat a wrong nested path here forever).
|
|
413
515
|
const basePath = path.join(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
|
|
414
|
-
|
|
516
|
+
let trackedCanopyMeta;
|
|
517
|
+
let releaseProvisioning;
|
|
518
|
+
let releaseContentLock;
|
|
415
519
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
gitDirStat = null;
|
|
520
|
+
// Held to the end, so no git step below races a clone of this directory.
|
|
521
|
+
const hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
|
|
522
|
+
if (hold.kind === 'locked') {
|
|
523
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): provisioning lock held elsewhere, skipping refresh`);
|
|
524
|
+
return { outcome: 'skipped-locked' };
|
|
422
525
|
}
|
|
423
|
-
if (
|
|
526
|
+
if (hold.kind === 'not-provisioned') {
|
|
424
527
|
workerLog(`Base branch workspace (${ctx.baseBranch}): not yet provisioned, skipping refresh`);
|
|
425
|
-
return;
|
|
528
|
+
return { outcome: 'skipped-not-provisioned' };
|
|
426
529
|
}
|
|
530
|
+
releaseProvisioning = hold.release;
|
|
531
|
+
let contentLockCompromised = false;
|
|
532
|
+
try {
|
|
533
|
+
releaseContentLock = await tryAcquireContentWriteLock(basePath, (lockErr) => {
|
|
534
|
+
contentLockCompromised = true;
|
|
535
|
+
workerLogWarn(`Base branch workspace (${ctx.baseBranch}): content-write lock compromised mid-refresh: ${getErrorMessage(lockErr)}`);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch (lockErr) {
|
|
539
|
+
if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
|
|
540
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): content write in progress, skipping refresh`);
|
|
541
|
+
return { outcome: 'skipped-locked' };
|
|
542
|
+
}
|
|
543
|
+
throw lockErr;
|
|
544
|
+
}
|
|
545
|
+
// Checked before each destructive step: a lost provisioning lock means
|
|
546
|
+
// another process may be cloning into this directory, a lost content lock
|
|
547
|
+
// that an editor save may be landing in it.
|
|
548
|
+
const lockLost = () => {
|
|
549
|
+
if (!hold.isCompromised() && !contentLockCompromised)
|
|
550
|
+
return null;
|
|
551
|
+
const lost = hold.isCompromised() ? 'provisioning' : 'content-write';
|
|
552
|
+
workerLogWarn(`Base branch workspace (${ctx.baseBranch}): ${lost} lock lost mid-refresh, stopping`);
|
|
553
|
+
return { outcome: 'skipped-locked', trackedCanopyMeta };
|
|
554
|
+
};
|
|
555
|
+
// Idempotent, and applied every cycle so any clone lacking it gets it.
|
|
556
|
+
await ensureGitExcludePattern(basePath, `${CANOPY_META_DIR}/`);
|
|
427
557
|
const baseGit = simpleGit({
|
|
428
558
|
baseDir: basePath,
|
|
429
559
|
// Keep git non-interactive during the merge so it never blocks on an
|
|
@@ -437,37 +567,80 @@ export async function refreshBaseBranchWorkspace(ctx) {
|
|
|
437
567
|
// Inactivity-based, so a slow-but-flowing transfer is unaffected.
|
|
438
568
|
timeout: { block: ctx.taskTimeoutMs },
|
|
439
569
|
});
|
|
570
|
+
const trackedState = await listTrackedCanopyState(baseGit);
|
|
571
|
+
if (trackedState.length > 0) {
|
|
572
|
+
trackedCanopyMeta = trackedState.slice(0, MAX_REPORTED_PATHS);
|
|
573
|
+
const warnKey = `${basePath}\0${trackedState.join('\0')}`;
|
|
574
|
+
if (!warnedTrackedCanopyState.has(warnKey)) {
|
|
575
|
+
warnedTrackedCanopyState.add(warnKey);
|
|
576
|
+
workerLogWarn(`Base branch (${ctx.baseBranch}) tracks canopycms state that must not be committed: ` +
|
|
577
|
+
`${trackedState.join(', ')}. To fix, ${TRACKED_CANOPY_STATE_FIX}.`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
let status = await baseGit.status();
|
|
581
|
+
const lostBeforeRestore = lockLost();
|
|
582
|
+
if (lostBeforeRestore)
|
|
583
|
+
return lostBeforeRestore;
|
|
584
|
+
if (await restoreRetiredSchemaCache(baseGit, status)) {
|
|
585
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): restored the retired in-tree schema cache`);
|
|
586
|
+
status = await baseGit.status();
|
|
587
|
+
}
|
|
440
588
|
// Nothing makes this clone read-only, and a direct edit here wedges every
|
|
441
589
|
// 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
|
-
|
|
590
|
+
// tree is loud, not a quiet skip. Only TRACKED content changes block the
|
|
591
|
+
// refresh: canopycms's own state never does, and neither does a stray
|
|
592
|
+
// untracked file. A modified tracked file or an untracked one that would
|
|
593
|
+
// collide with incoming content makes the --ff-only merge below refuse and
|
|
594
|
+
// report `failed`; an IGNORED file, which every .canopy-meta file is, git
|
|
595
|
+
// overwrites instead.
|
|
596
|
+
const trackedDirty = status.files
|
|
597
|
+
.filter((f) => !isUntracked(f) && !isCanopyInternalPath(f.path))
|
|
598
|
+
.map((f) => f.path);
|
|
448
599
|
if (trackedDirty.length > 0) {
|
|
449
|
-
workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.
|
|
450
|
-
return
|
|
600
|
+
workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.join(', ')}`);
|
|
601
|
+
return {
|
|
602
|
+
outcome: 'skipped-dirty',
|
|
603
|
+
dirtyFiles: trackedDirty.slice(0, MAX_REPORTED_PATHS),
|
|
604
|
+
message: `${trackedDirty.length} uncommitted tracked file(s) in the base branch workspace`,
|
|
605
|
+
trackedCanopyMeta,
|
|
606
|
+
};
|
|
451
607
|
}
|
|
452
608
|
// Raw (unsanitized) name from here on: these are git ref operations
|
|
453
|
-
// against
|
|
609
|
+
// against remote.git's <baseBranch>, not filesystem paths, so they must use
|
|
454
610
|
// the same name GitHub knows the branch by.
|
|
455
|
-
await baseGit.fetch(
|
|
611
|
+
await baseGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
|
|
456
612
|
// rev-list, not status.behind, which needs an upstream tracking branch that
|
|
457
|
-
// is not guaranteed here. Against the just-fetched tip
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
// with "ambiguous argument". Pin FETCH_HEAD to a SHA immediately -- it is
|
|
461
|
-
// one shared mutable file per repo, silently repointed by any other fetch.
|
|
613
|
+
// is not guaranteed here. Against the just-fetched tip: a fetch by path
|
|
614
|
+
// updates no remote-tracking ref. Pin FETCH_HEAD to a SHA immediately -- it
|
|
615
|
+
// is one shared mutable file per repo, silently repointed by any other fetch.
|
|
462
616
|
const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
|
|
463
617
|
const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
|
|
464
618
|
if (behindCount > 0) {
|
|
619
|
+
const lostBeforeMerge = lockLost();
|
|
620
|
+
if (lostBeforeMerge)
|
|
621
|
+
return lostBeforeMerge;
|
|
622
|
+
// Untrack, in the index only, any state the tip has stopped tracking:
|
|
623
|
+
// the merge would otherwise refuse to overwrite a modified copy, or
|
|
624
|
+
// delete a clean one from disk. Safe here and not in the rebase loop,
|
|
625
|
+
// because this clone has no commits of its own to replay. State still
|
|
626
|
+
// tracked upstream is left for the merge, which refuses where upstream
|
|
627
|
+
// changed a locally modified copy.
|
|
628
|
+
const { droppedUpstream } = await splitByUpstreamTracking(baseGit, trackedState, fetchedTip);
|
|
629
|
+
if (droppedUpstream.length > 0) {
|
|
630
|
+
await untrackInIndex(baseGit, droppedUpstream);
|
|
631
|
+
workerLog(`Base branch workspace (${ctx.baseBranch}): stopped tracking ${droppedUpstream.join(', ')}, as upstream has`);
|
|
632
|
+
}
|
|
465
633
|
try {
|
|
466
634
|
await baseGit.merge(['--ff-only', fetchedTip]);
|
|
467
635
|
}
|
|
468
636
|
catch (err) {
|
|
469
637
|
workerLogError(`Base branch workspace (${ctx.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
|
|
470
|
-
return
|
|
638
|
+
return {
|
|
639
|
+
outcome: 'failed',
|
|
640
|
+
// [REDACT] Served to the browser by the admin panel.
|
|
641
|
+
message: redactCredentials(`failed to fast-forward: ${getErrorMessage(err)}`),
|
|
642
|
+
trackedCanopyMeta,
|
|
643
|
+
};
|
|
471
644
|
}
|
|
472
645
|
await invalidateBranchContentCaches(basePath);
|
|
473
646
|
}
|
|
@@ -491,8 +664,30 @@ export async function refreshBaseBranchWorkspace(ctx) {
|
|
|
491
664
|
workerLog(behindCount > 0
|
|
492
665
|
? `Base branch workspace (${ctx.baseBranch}): fast-forwarded ${behindCount} commit(s)`
|
|
493
666
|
: `Base branch workspace (${ctx.baseBranch}): up to date`);
|
|
667
|
+
if (trackedCanopyMeta && behindCount > 0) {
|
|
668
|
+
const stillTracked = await listTrackedCanopyState(baseGit);
|
|
669
|
+
trackedCanopyMeta =
|
|
670
|
+
stillTracked.length > 0 ? stillTracked.slice(0, MAX_REPORTED_PATHS) : undefined;
|
|
671
|
+
}
|
|
672
|
+
return { outcome: behindCount > 0 ? 'refreshed' : 'up-to-date', trackedCanopyMeta };
|
|
494
673
|
}
|
|
495
674
|
catch (err) {
|
|
496
675
|
workerLogError(`Base branch workspace (${ctx.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
|
|
676
|
+
// [REDACT] Served to the browser by the admin panel.
|
|
677
|
+
return {
|
|
678
|
+
outcome: 'failed',
|
|
679
|
+
message: redactCredentials(getErrorMessage(err)),
|
|
680
|
+
trackedCanopyMeta,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
finally {
|
|
684
|
+
if (releaseContentLock) {
|
|
685
|
+
await releaseContentLock().catch((err) => {
|
|
686
|
+
workerLogWarn(`Base branch workspace (${ctx.baseBranch}): failed to release content-write lock: ${getErrorMessage(err)}`);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (releaseProvisioning) {
|
|
690
|
+
await releaseProvisionedWorkspace(releaseProvisioning, ctx.sanitizedBaseBranch);
|
|
691
|
+
}
|
|
497
692
|
}
|
|
498
693
|
}
|
|
@@ -72,7 +72,7 @@ export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteCont
|
|
|
72
72
|
* Returns whether the push landed. A refused lease means a concurrent Lambda
|
|
73
73
|
* push moved the ref; logged and retried by the self-heal pass, never thrown.
|
|
74
74
|
*/
|
|
75
|
-
export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
|
|
75
|
+
export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'remoteGitPath' | 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
|
|
76
76
|
/**
|
|
77
77
|
* Queue the GitHub hop for a branch whose rewritten history now sits in
|
|
78
78
|
* `remote.git`, so an open PR's head follows the rebase within a cycle instead
|
|
@@ -108,7 +108,7 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
|
|
|
108
108
|
// Real flags must precede --end-of-options; everything after it is
|
|
109
109
|
// positional (see GitManager.push() for the same guard).
|
|
110
110
|
'--end-of-options',
|
|
111
|
-
|
|
111
|
+
ctx.remoteGitPath,
|
|
112
112
|
`${branchRef}:${branchRef}`,
|
|
113
113
|
]);
|
|
114
114
|
workerLog(` Published rebased ${branchRef} into remote.git`);
|