canopycms 0.0.67 → 0.0.68-int.100
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 +21 -14
- 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 +111 -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 +10 -1
- package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +122 -0
- package/dist/assets/materialize.js +305 -0
- package/dist/assets/store-local.d.ts +4 -0
- package/dist/assets/store-local.js +11 -0
- package/dist/assets/store-s3.d.ts +10 -1
- package/dist/assets/store-s3.js +55 -3
- 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 +14 -1
- 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 +30 -0
- package/dist/cli/asset-refs.js +168 -0
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.js +4843 -1870
- package/dist/cli/generate-ai-content.js +1584 -444
- 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/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 +24 -20
- package/dist/editor/FormRenderer.js +34 -18
- 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 +11 -0
- package/dist/editor/hooks/useBranchManager.js +80 -2
- 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 +8 -1
- package/dist/editor/preview-bridge.js +15 -2
- 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 +8 -0
- package/dist/github-service.js +5 -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/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 +24 -9
- package/dist/server.js +22 -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-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/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 +39 -4
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +2 -1
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`);
|
|
@@ -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 publishes the workspace (branch-provisioning.ts) and admin actions hold
|
|
22
|
+
* while they repair or purge it. 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`. A workspace is published complete with
|
|
30
|
+
* both ([PROV-1], branch-provisioning.ts), so anything else at a branch name is a leftover for
|
|
31
|
+
* `repairBranchDirResidue` (git-sync.ts).
|
|
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 publishes the workspace (branch-provisioning.ts) and admin actions hold
|
|
10
|
+
* while they repair or purge it. 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`. A workspace is published complete with
|
|
18
|
+
* both ([PROV-1], branch-provisioning.ts), so anything else at a branch name is a leftover for
|
|
19
|
+
* `repairBranchDirResidue` (git-sync.ts).
|
|
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
|