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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { type BranchMetadataFile } from './branch-metadata-file.js';
|
|
2
|
+
import { type CloneRepoOptions } from './git-manager.js';
|
|
3
|
+
import type { ProvisionLog } from './utils/provision-log.js';
|
|
4
|
+
/** How long residue must have been untouched before a create request moves it aside. */
|
|
5
|
+
export declare const CREATE_RESIDUE_MIN_QUIET_MS = 60000;
|
|
6
|
+
/**
|
|
7
|
+
* Retriable: another process is setting this branch up, or what is at its path is too recently
|
|
8
|
+
* touched (or under an admin repair) to judge. Callers answer 503.
|
|
9
|
+
*/
|
|
10
|
+
export declare class BranchProvisioningBusyError extends Error {
|
|
11
|
+
readonly dirName: string;
|
|
12
|
+
constructor(dirName: string, message?: string);
|
|
13
|
+
}
|
|
14
|
+
/** The branch's path holds a git repository this deployment did not create (prod only). */
|
|
15
|
+
export declare class BranchDirOccupiedError extends Error {
|
|
16
|
+
readonly dirName: string;
|
|
17
|
+
constructor(dirName: string);
|
|
18
|
+
}
|
|
19
|
+
/** `YYYYMMDDTHHMMSSZ` in UTC, with no colons. */
|
|
20
|
+
export declare function formatDirStamp(date: Date): string;
|
|
21
|
+
/**
|
|
22
|
+
* The date in a name ending `-{STAMP}`, or null. Leftovers are aged by this alone, never by
|
|
23
|
+
* mtime: `rename()` keeps a directory's mtime, so a months-old residue would look old the
|
|
24
|
+
* moment it was moved.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseDirStamp(name: string): Date | null;
|
|
27
|
+
/**
|
|
28
|
+
* A branch directory name cut to a bounded, still-unique form: branch names run to 250 chars.
|
|
29
|
+
* @internal Exported for tests.
|
|
30
|
+
*/
|
|
31
|
+
export declare function shortDirName(dirName: string): string;
|
|
32
|
+
/** Where a branch directory goes the moment it is deleted, before its `rm`. */
|
|
33
|
+
export declare const deletingDirName: (dirName: string, now?: Date) => string;
|
|
34
|
+
/** The settings workspace's staging and trash siblings, under the workspace root. */
|
|
35
|
+
export declare const settingsStagingDirName: (now?: Date) => string;
|
|
36
|
+
export declare const settingsTrashDirName: (now?: Date) => string;
|
|
37
|
+
interface ResidueSignature {
|
|
38
|
+
hasGitDir: boolean;
|
|
39
|
+
configLock: boolean;
|
|
40
|
+
hasIndex: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* What occupies a branch's final path.
|
|
44
|
+
*
|
|
45
|
+
* - `live` / `corrupt`: branch.json is there. Never touched by provisioning.
|
|
46
|
+
* - `protected`: an admin repair-metadata is mid-way (`branch.json.corrupt-*` with no
|
|
47
|
+
* branch.json), or branch.json vanished between the listing and the read. Left alone.
|
|
48
|
+
* - `residue`: nothing this deployment could still be using — no `.git`, or a `.git` whose config
|
|
49
|
+
* is missing or unreadable (an interrupted clone), or one whose remote is this deployment's own
|
|
50
|
+
* (an interrupted clone or delete).
|
|
51
|
+
* - `foreign`: any other repository, such as a dev CLI-sync workspace, which has no remote.
|
|
52
|
+
*/
|
|
53
|
+
export type FinalDirState = {
|
|
54
|
+
kind: 'vacant';
|
|
55
|
+
} | {
|
|
56
|
+
kind: 'live';
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'corrupt';
|
|
59
|
+
} | {
|
|
60
|
+
kind: 'protected';
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'foreign';
|
|
63
|
+
} | {
|
|
64
|
+
kind: 'residue';
|
|
65
|
+
signature: ResidueSignature;
|
|
66
|
+
};
|
|
67
|
+
/** Classify what is at `dirPath`; see {@link FinalDirState}. */
|
|
68
|
+
export declare function classifyFinalDir(dirPath: string, expectedRemoteUrl: string): Promise<FinalDirState>;
|
|
69
|
+
/**
|
|
70
|
+
* Milliseconds since anything was last created or removed in the directory, its `.git` or its
|
|
71
|
+
* `.canopy-meta`. A clone killed inside `git config` touched `.git` when it made `config.lock`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function quietAgeMs(dirPath: string, now?: number): Promise<number>;
|
|
74
|
+
type KeptReason = 'live' | 'corrupt' | 'protected' | 'foreign' | 'too-young' | 'replaced';
|
|
75
|
+
export type QuarantineResult = {
|
|
76
|
+
kind: 'quarantined';
|
|
77
|
+
trashName: string;
|
|
78
|
+
} | {
|
|
79
|
+
kind: 'vacant';
|
|
80
|
+
} | {
|
|
81
|
+
kind: 'kept';
|
|
82
|
+
reason: KeptReason;
|
|
83
|
+
};
|
|
84
|
+
export interface QuarantineOptions {
|
|
85
|
+
minQuietMs: number;
|
|
86
|
+
expectedRemoteUrl: string;
|
|
87
|
+
now?: number;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Move residue at `baseRoot/dirName` to `.trash-*`. The caller MUST hold the branch's
|
|
91
|
+
* provisioning lock: admin repair-metadata holds it while it legitimately leaves a directory
|
|
92
|
+
* with real edits and no branch.json.
|
|
93
|
+
*
|
|
94
|
+
* Move-then-verify: the name is renamed aside first and only what arrived at the new name is
|
|
95
|
+
* judged — the same inode the old name showed, still residue, quiet for `minQuietMs` — because
|
|
96
|
+
* a look through the old name can come from a stale NFS dentry for a directory another host has
|
|
97
|
+
* since replaced with a live clone. Anything that fails a check, or cannot be checked, is
|
|
98
|
+
* renamed straight back: the sweep would trash a `.repair-*` without branch.json.
|
|
99
|
+
*/
|
|
100
|
+
export declare function quarantineResidueAt(baseRoot: string, dirName: string, options: QuarantineOptions): Promise<QuarantineResult>;
|
|
101
|
+
export interface StageBranchOptions {
|
|
102
|
+
baseRoot: string;
|
|
103
|
+
dirName: string;
|
|
104
|
+
branchName: string;
|
|
105
|
+
baseBranch: string;
|
|
106
|
+
remoteUrl: string;
|
|
107
|
+
clone: Pick<CloneRepoOptions, 'remoteName' | 'config'>;
|
|
108
|
+
gitExcludePattern: string;
|
|
109
|
+
metadata: BranchMetadataFile;
|
|
110
|
+
provisionLog: ProvisionLog;
|
|
111
|
+
/** The sparse-checkout cone (branch-sparse.ts), set before the checkout; none for a full clone. */
|
|
112
|
+
sparseCone?: readonly string[] | null;
|
|
113
|
+
}
|
|
114
|
+
export type StageResult = {
|
|
115
|
+
kind: 'staged';
|
|
116
|
+
stagingPath: string;
|
|
117
|
+
meta: BranchMetadataFile;
|
|
118
|
+
} | {
|
|
119
|
+
kind: 'exists';
|
|
120
|
+
};
|
|
121
|
+
/** Remove a staging or deleted directory, leaving it to the worker's sweep on failure. */
|
|
122
|
+
export declare function removeLeftoverDir(dirPath: string): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Build a complete branch workspace, branch.json included, in a staging sibling of the final
|
|
125
|
+
* path. Takes no lock. Returns `exists` without checking out when a competitor published first.
|
|
126
|
+
* A failure removes the staging directory; a kill leaves it for the worker.
|
|
127
|
+
*/
|
|
128
|
+
export declare function stageBranchWorkspace(options: StageBranchOptions): Promise<StageResult>;
|
|
129
|
+
/**
|
|
130
|
+
* The branch's provisioning lock with a bounded wait; contention is
|
|
131
|
+
* {@link BranchProvisioningBusyError}.
|
|
132
|
+
*/
|
|
133
|
+
export declare function lockBranchName(baseRoot: string, dirName: string): Promise<() => Promise<void>>;
|
|
134
|
+
/** What blocks a refused publish rename, as {@link publishStaging} sees it. */
|
|
135
|
+
export type BlockingKind = 'live' | 'protected' | 'residue';
|
|
136
|
+
export interface PublishOptions {
|
|
137
|
+
baseRoot: string;
|
|
138
|
+
dirName: string;
|
|
139
|
+
stagingPath: string;
|
|
140
|
+
/** branch.json's `writeId` in the staged workspace: proof the rename was ours. */
|
|
141
|
+
writeId: string | undefined;
|
|
142
|
+
expectedRemoteUrl: string;
|
|
143
|
+
/** Test seam replacing the look at what blocks a refused rename. */
|
|
144
|
+
inspectBlocked?: (finalPath: string) => Promise<BlockingKind>;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Publish a staged workspace at its final path, under the branch's provisioning lock.
|
|
148
|
+
*
|
|
149
|
+
* A refused rename is inspected: branch.json there means another request won (`exists`);
|
|
150
|
+
* residue is quarantined and the rename retried once. ENOENT can be an NFS retransmission of a
|
|
151
|
+
* rename that already landed, so it counts as published when the final branch.json carries our
|
|
152
|
+
* `writeId`. The caller removes the staging directory on any outcome but `published`.
|
|
153
|
+
*/
|
|
154
|
+
export declare function publishStaging(options: PublishOptions): Promise<'published' | 'exists'>;
|
|
155
|
+
export interface LeftoverAction {
|
|
156
|
+
name: string;
|
|
157
|
+
action: 'removed' | 'restored' | 'trashed' | 'failed';
|
|
158
|
+
detail?: string;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Clear what killed processes left beside the branch directories (`baseRoot`) and the settings
|
|
162
|
+
* workspace (`workspaceRoot`): stale `.prov-*` builds, `.deleting-*` directories whose `rm` never
|
|
163
|
+
* finished, `.repair-*` from an interrupted quarantine, and expired settings trash. Ages come
|
|
164
|
+
* from the stamp in each name.
|
|
165
|
+
*/
|
|
166
|
+
export declare function sweepProvisioningLeftovers(baseRoot: string, workspaceRoot: string, now?: number): Promise<LeftoverAction[]>;
|
|
167
|
+
export {};
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe provisioning of content-branch workspaces.
|
|
3
|
+
*
|
|
4
|
+
* [PROV-1] A final branch directory only ever appears by `rename()`, with
|
|
5
|
+
* `.canopy-meta/branch.json` already inside it. A Lambda can be killed at its
|
|
6
|
+
* timeout inside any git step, and anything it was building at the final path
|
|
7
|
+
* would wedge that branch name for every later request. So a workspace is built
|
|
8
|
+
* in a dot-prefixed staging sibling without any lock (the registry,
|
|
9
|
+
* branch-health, the rebase loop and admin all skip dot-prefixed names), then
|
|
10
|
+
* published by one rename under the branch's provisioning lock, held for
|
|
11
|
+
* milliseconds. A kill at any point leaves nothing at the final name or a
|
|
12
|
+
* complete workspace; the worker sweeps the leftovers.
|
|
13
|
+
*
|
|
14
|
+
* The rename alone does not settle a race: it silently replaces an EMPTY
|
|
15
|
+
* directory, and a refused rename may be blocked by a live branch or by
|
|
16
|
+
* residue. Hence the lock, and the inspection of whatever blocks the rename.
|
|
17
|
+
* docs/concurrency.md has the cross-host picture.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, readBranchMetadataFile, } from './branch-metadata-file.js';
|
|
23
|
+
import { GitManager } from './git-manager.js';
|
|
24
|
+
import { sanitizeBranchName } from './paths/branch-name.js';
|
|
25
|
+
import { getErrorMessage, isNodeError, isNotFoundError } from './utils/error.js';
|
|
26
|
+
import { isNetworkRemoteUrl } from './utils/git.js';
|
|
27
|
+
import { canopyLogError, canopyLogWarn } from './utils/logger.js';
|
|
28
|
+
import { writeOccJsonFile } from './utils/occ-json-write.js';
|
|
29
|
+
import { acquireProvisioningLockWithin, branchProvisioningLockName, tryAcquireProvisioningLock, } from './utils/provisioning-lock.js';
|
|
30
|
+
/**
|
|
31
|
+
* How long a publish waits for the branch's provisioning lock. Holds on a name being published
|
|
32
|
+
* are brief; a dead holder's marker stays live for 90 s, and waiting that out would spend the
|
|
33
|
+
* request's timeout.
|
|
34
|
+
*/
|
|
35
|
+
const PUBLISH_LOCK_WAIT_MS = 8_000;
|
|
36
|
+
/** How long residue must have been untouched before a create request moves it aside. */
|
|
37
|
+
export const CREATE_RESIDUE_MIN_QUIET_MS = 60_000;
|
|
38
|
+
/**
|
|
39
|
+
* Stamped leftovers older than these are swept by the worker. A `.prov-*` may belong to a live
|
|
40
|
+
* builder for as long as a Lambda can run (900 s at most, whatever timeout an adopter sets).
|
|
41
|
+
*/
|
|
42
|
+
const STAGING_MAX_AGE_MS = 20 * 60_000;
|
|
43
|
+
const REPAIR_MAX_AGE_MS = 20 * 60_000;
|
|
44
|
+
const DELETING_MAX_AGE_MS = 10 * 60_000;
|
|
45
|
+
const SETTINGS_TRASH_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
|
|
46
|
+
/**
|
|
47
|
+
* Retriable: another process is setting this branch up, or what is at its path is too recently
|
|
48
|
+
* touched (or under an admin repair) to judge. Callers answer 503.
|
|
49
|
+
*/
|
|
50
|
+
export class BranchProvisioningBusyError extends Error {
|
|
51
|
+
constructor(dirName, message = `Branch '${dirName}' is still being set up. Try again in a minute.`) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.dirName = dirName;
|
|
54
|
+
this.name = 'BranchProvisioningBusyError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** The branch's path holds a git repository this deployment did not create (prod only). */
|
|
58
|
+
export class BranchDirOccupiedError extends Error {
|
|
59
|
+
constructor(dirName) {
|
|
60
|
+
super(`A directory named '${dirName}' already exists in the branch workspace and holds a git ` +
|
|
61
|
+
`repository this deployment did not create. It was left untouched; an admin can purge ` +
|
|
62
|
+
`it from System Health, or choose another branch name.`);
|
|
63
|
+
this.dirName = dirName;
|
|
64
|
+
this.name = 'BranchDirOccupiedError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// --- Names -----------------------------------------------------------------------------------
|
|
68
|
+
const DIR_STAMP_RE = /-(\d{8}T\d{6}Z)$/;
|
|
69
|
+
/** `YYYYMMDDTHHMMSSZ` in UTC, with no colons. */
|
|
70
|
+
export function formatDirStamp(date) {
|
|
71
|
+
return date
|
|
72
|
+
.toISOString()
|
|
73
|
+
.replace(/[-:]/g, '')
|
|
74
|
+
.replace(/\.\d{3}Z$/, 'Z');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The date in a name ending `-{STAMP}`, or null. Leftovers are aged by this alone, never by
|
|
78
|
+
* mtime: `rename()` keeps a directory's mtime, so a months-old residue would look old the
|
|
79
|
+
* moment it was moved.
|
|
80
|
+
*/
|
|
81
|
+
export function parseDirStamp(name) {
|
|
82
|
+
const match = DIR_STAMP_RE.exec(name);
|
|
83
|
+
if (!match)
|
|
84
|
+
return null;
|
|
85
|
+
const [, year, month, day, hour, minute, second] = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(match[1]) ?? [];
|
|
86
|
+
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`);
|
|
87
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A branch directory name cut to a bounded, still-unique form: branch names run to 250 chars.
|
|
91
|
+
* @internal Exported for tests.
|
|
92
|
+
*/
|
|
93
|
+
export function shortDirName(dirName) {
|
|
94
|
+
const digest = createHash('sha1').update(dirName).digest('hex').slice(0, 10);
|
|
95
|
+
return `${dirName.slice(0, 40)}-${digest}`;
|
|
96
|
+
}
|
|
97
|
+
function stampedName(prefix, dirName, now) {
|
|
98
|
+
return `${prefix}${shortDirName(dirName)}-${randomBytes(3).toString('hex')}-${formatDirStamp(now)}`;
|
|
99
|
+
}
|
|
100
|
+
const STAGING_PREFIX = '.prov-';
|
|
101
|
+
const REPAIR_PREFIX = '.repair-';
|
|
102
|
+
const TRASH_PREFIX = '.trash-';
|
|
103
|
+
const DELETING_PREFIX = '.deleting-';
|
|
104
|
+
/** Where a branch is built before it is published. */
|
|
105
|
+
const stagingDirName = (dirName, now = new Date()) => stampedName(STAGING_PREFIX, dirName, now);
|
|
106
|
+
/** Where a branch directory goes the moment it is deleted, before its `rm`. */
|
|
107
|
+
export const deletingDirName = (dirName, now = new Date()) => stampedName(DELETING_PREFIX, dirName, now);
|
|
108
|
+
/** The settings workspace's staging and trash siblings, under the workspace root. */
|
|
109
|
+
export const settingsStagingDirName = (now = new Date()) => `${STAGING_PREFIX}settings-${randomBytes(3).toString('hex')}-${formatDirStamp(now)}`;
|
|
110
|
+
export const settingsTrashDirName = (now = new Date()) => `${TRASH_PREFIX}settings-${randomBytes(3).toString('hex')}-${formatDirStamp(now)}`;
|
|
111
|
+
const NO_GIT = { hasGitDir: false, configLock: false, hasIndex: false };
|
|
112
|
+
async function listDir(dirPath) {
|
|
113
|
+
try {
|
|
114
|
+
return await fs.readdir(dirPath);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (isNodeError(err) && (err.code === 'ENOENT' || err.code === 'ENOTDIR'))
|
|
118
|
+
return null;
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Remote URLs in a git config file's text. Read as a file: git does not treat a `.git` that a
|
|
123
|
+
* kill left without `HEAD` as a repository, and a stale `config.lock` blocks only config writes. */
|
|
124
|
+
function gitConfigRemoteUrls(text) {
|
|
125
|
+
const urls = [];
|
|
126
|
+
let inRemote = false;
|
|
127
|
+
for (const raw of text.split('\n')) {
|
|
128
|
+
const line = raw.trim();
|
|
129
|
+
if (line.startsWith('[')) {
|
|
130
|
+
inRemote = /^\[\s*remote\s+"[^"]*"\s*\]/i.test(line);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const match = inRemote ? /^url\s*=\s*(.*)$/i.exec(line) : null;
|
|
134
|
+
if (match)
|
|
135
|
+
urls.push(match[1].trim().replace(/^"(.*)"$/, '$1'));
|
|
136
|
+
}
|
|
137
|
+
return urls;
|
|
138
|
+
}
|
|
139
|
+
function sameRemote(url, expected) {
|
|
140
|
+
if (isNetworkRemoteUrl(url) || isNetworkRemoteUrl(expected))
|
|
141
|
+
return url === expected;
|
|
142
|
+
const local = (value) => path.resolve(value.replace(/^file:\/\//i, ''));
|
|
143
|
+
return local(url) === local(expected);
|
|
144
|
+
}
|
|
145
|
+
/** Classify what is at `dirPath`; see {@link FinalDirState}. */
|
|
146
|
+
export async function classifyFinalDir(dirPath, expectedRemoteUrl) {
|
|
147
|
+
let stat;
|
|
148
|
+
try {
|
|
149
|
+
stat = await fs.lstat(dirPath);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (isNotFoundError(err))
|
|
153
|
+
return { kind: 'vacant' };
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
if (!stat.isDirectory())
|
|
157
|
+
return { kind: 'residue', signature: NO_GIT };
|
|
158
|
+
const metaEntries = (await listDir(path.join(dirPath, BRANCH_META_DIR))) ?? [];
|
|
159
|
+
if (metaEntries.includes(BRANCH_META_FILE)) {
|
|
160
|
+
try {
|
|
161
|
+
return (await readBranchMetadataFile(dirPath)) ? { kind: 'live' } : { kind: 'protected' };
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
if (err instanceof BranchMetadataCorruptError)
|
|
165
|
+
return { kind: 'corrupt' };
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (metaEntries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`))) {
|
|
170
|
+
return { kind: 'protected' };
|
|
171
|
+
}
|
|
172
|
+
const gitPath = path.join(dirPath, '.git');
|
|
173
|
+
let gitStat;
|
|
174
|
+
try {
|
|
175
|
+
gitStat = await fs.lstat(gitPath);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
if (isNotFoundError(err))
|
|
179
|
+
return { kind: 'residue', signature: NO_GIT };
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
if (!gitStat.isDirectory())
|
|
183
|
+
return { kind: 'foreign' };
|
|
184
|
+
const gitEntries = (await listDir(gitPath)) ?? [];
|
|
185
|
+
const signature = {
|
|
186
|
+
hasGitDir: true,
|
|
187
|
+
configLock: gitEntries.includes('config.lock'),
|
|
188
|
+
hasIndex: gitEntries.includes('index'),
|
|
189
|
+
};
|
|
190
|
+
let config;
|
|
191
|
+
try {
|
|
192
|
+
config = await fs.readFile(path.join(gitPath, 'config'), 'utf8');
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return { kind: 'residue', signature };
|
|
196
|
+
}
|
|
197
|
+
return gitConfigRemoteUrls(config).some((url) => sameRemote(url, expectedRemoteUrl))
|
|
198
|
+
? { kind: 'residue', signature }
|
|
199
|
+
: { kind: 'foreign' };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Milliseconds since anything was last created or removed in the directory, its `.git` or its
|
|
203
|
+
* `.canopy-meta`. A clone killed inside `git config` touched `.git` when it made `config.lock`.
|
|
204
|
+
*/
|
|
205
|
+
export async function quietAgeMs(dirPath, now = Date.now()) {
|
|
206
|
+
const mtimes = await Promise.all(['', '.git', BRANCH_META_DIR].map((sub) => fs.lstat(path.join(dirPath, sub)).then((stat) => stat.mtimeMs, () => 0)));
|
|
207
|
+
return now - Math.max(...mtimes);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Move residue at `baseRoot/dirName` to `.trash-*`. The caller MUST hold the branch's
|
|
211
|
+
* provisioning lock: admin repair-metadata holds it while it legitimately leaves a directory
|
|
212
|
+
* with real edits and no branch.json.
|
|
213
|
+
*
|
|
214
|
+
* Move-then-verify: the name is renamed aside first and only what arrived at the new name is
|
|
215
|
+
* judged — the same inode the old name showed, still residue, quiet for `minQuietMs` — because
|
|
216
|
+
* a look through the old name can come from a stale NFS dentry for a directory another host has
|
|
217
|
+
* since replaced with a live clone. Anything that fails a check, or cannot be checked, is
|
|
218
|
+
* renamed straight back: the sweep would trash a `.repair-*` without branch.json.
|
|
219
|
+
*/
|
|
220
|
+
export async function quarantineResidueAt(baseRoot, dirName, options) {
|
|
221
|
+
const finalPath = path.join(baseRoot, dirName);
|
|
222
|
+
let inode;
|
|
223
|
+
try {
|
|
224
|
+
inode = (await fs.lstat(finalPath)).ino;
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (isNotFoundError(err))
|
|
228
|
+
return { kind: 'vacant' };
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
const candidateName = stampedName(REPAIR_PREFIX, dirName, new Date());
|
|
232
|
+
const candidate = path.join(baseRoot, candidateName);
|
|
233
|
+
try {
|
|
234
|
+
await fs.rename(finalPath, candidate);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
if (isNotFoundError(err))
|
|
238
|
+
return { kind: 'vacant' };
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
const moveBack = async (why) => {
|
|
242
|
+
try {
|
|
243
|
+
await fs.rename(candidate, finalPath);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
canopyLogError(`[canopy] Could not move '${dirName}' back from ${candidateName} after ${why}: ` +
|
|
247
|
+
`${getErrorMessage(err)}. The worker's sweep restores or trashes it.`);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const { state, quiet, reason } = await judgeQuarantineCandidate(candidate, inode, options).catch(async (err) => {
|
|
251
|
+
await moveBack(`failing to judge it (${getErrorMessage(err)})`);
|
|
252
|
+
throw err;
|
|
253
|
+
});
|
|
254
|
+
if (reason === undefined && state.kind === 'residue') {
|
|
255
|
+
const trashName = stampedName(TRASH_PREFIX, dirName, new Date());
|
|
256
|
+
await fs.rename(candidate, path.join(baseRoot, trashName));
|
|
257
|
+
const { hasGitDir, configLock, hasIndex } = state.signature;
|
|
258
|
+
canopyLogWarn(`[canopy] Quarantined unfinished branch directory '${dirName}' as ${trashName} ` +
|
|
259
|
+
`(git=${hasGitDir} config.lock=${configLock} index=${hasIndex} quiet=${Math.round(quiet / 1000)}s)`);
|
|
260
|
+
return { kind: 'quarantined', trashName };
|
|
261
|
+
}
|
|
262
|
+
await moveBack(`declining to quarantine it (${reason})`);
|
|
263
|
+
return { kind: 'kept', reason: reason ?? 'replaced' };
|
|
264
|
+
}
|
|
265
|
+
/** Judge what arrived at the quarantine candidate name; `reason` is set when it must be kept. */
|
|
266
|
+
async function judgeQuarantineCandidate(candidate, inode, options) {
|
|
267
|
+
const state = await classifyFinalDir(candidate, options.expectedRemoteUrl);
|
|
268
|
+
const moved = await fs.lstat(candidate).catch(() => null);
|
|
269
|
+
const quiet = await quietAgeMs(candidate, options.now);
|
|
270
|
+
let reason;
|
|
271
|
+
if (!moved || moved.ino !== inode)
|
|
272
|
+
reason = 'replaced';
|
|
273
|
+
else if (state.kind === 'vacant')
|
|
274
|
+
reason = 'replaced';
|
|
275
|
+
else if (state.kind !== 'residue')
|
|
276
|
+
reason = state.kind;
|
|
277
|
+
else if (quiet < options.minQuietMs)
|
|
278
|
+
reason = 'too-young';
|
|
279
|
+
return { state, quiet, reason };
|
|
280
|
+
}
|
|
281
|
+
/** Remove a staging or deleted directory, leaving it to the worker's sweep on failure. */
|
|
282
|
+
export async function removeLeftoverDir(dirPath) {
|
|
283
|
+
try {
|
|
284
|
+
await fs.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
canopyLogWarn(`[canopy] Could not remove ${dirPath}; the worker sweeps it: ${getErrorMessage(err)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function hasBranchMetadata(branchRoot) {
|
|
291
|
+
return fs.access(path.join(branchRoot, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Build a complete branch workspace, branch.json included, in a staging sibling of the final
|
|
295
|
+
* path. Takes no lock. Returns `exists` without checking out when a competitor published first.
|
|
296
|
+
* A failure removes the staging directory; a kill leaves it for the worker.
|
|
297
|
+
*/
|
|
298
|
+
export async function stageBranchWorkspace(options) {
|
|
299
|
+
const { provisionLog: log } = options;
|
|
300
|
+
const stagingPath = path.join(options.baseRoot, stagingDirName(options.dirName));
|
|
301
|
+
try {
|
|
302
|
+
await log.step('clone', () => GitManager.cloneWorkspace(options.remoteUrl, stagingPath, options.baseBranch, {
|
|
303
|
+
...options.clone,
|
|
304
|
+
noCheckout: true,
|
|
305
|
+
}));
|
|
306
|
+
const git = new GitManager({
|
|
307
|
+
repoPath: stagingPath,
|
|
308
|
+
baseBranch: options.baseBranch,
|
|
309
|
+
remote: options.clone.remoteName,
|
|
310
|
+
});
|
|
311
|
+
const cone = options.sparseCone;
|
|
312
|
+
if (cone)
|
|
313
|
+
await log.step('sparse', () => git.setSparseCone(cone));
|
|
314
|
+
// The checkout is the expensive half; skip it when a competitor has already won.
|
|
315
|
+
if (await hasBranchMetadata(path.join(options.baseRoot, options.dirName))) {
|
|
316
|
+
await removeLeftoverDir(stagingPath);
|
|
317
|
+
return { kind: 'exists' };
|
|
318
|
+
}
|
|
319
|
+
await log.step('checkout', () => git.checkoutFreshClone(options.branchName));
|
|
320
|
+
await log.step('exclude', () => git.ensureGitExclude(options.gitExcludePattern));
|
|
321
|
+
const written = await log.step('metadata', () => writeOccJsonFile(path.join(stagingPath, BRANCH_META_DIR, BRANCH_META_FILE), { ...options.metadata }, { expectedVersion: null, trailingNewline: true }));
|
|
322
|
+
return {
|
|
323
|
+
kind: 'staged',
|
|
324
|
+
stagingPath,
|
|
325
|
+
meta: { ...options.metadata, version: written.version, writeId: written.writeId },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
await removeLeftoverDir(stagingPath);
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The branch's provisioning lock with a bounded wait; contention is
|
|
335
|
+
* {@link BranchProvisioningBusyError}.
|
|
336
|
+
*/
|
|
337
|
+
export async function lockBranchName(baseRoot, dirName) {
|
|
338
|
+
try {
|
|
339
|
+
return await acquireProvisioningLockWithin(baseRoot, branchProvisioningLockName(dirName), PUBLISH_LOCK_WAIT_MS);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
if (isNodeError(err) && err.code === 'ELOCKED')
|
|
343
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Listing `.canopy-meta` opens the directory, which makes NFS revalidate it. */
|
|
348
|
+
async function inspectBlocking(finalPath) {
|
|
349
|
+
const entries = (await listDir(path.join(finalPath, BRANCH_META_DIR))) ?? [];
|
|
350
|
+
if (entries.includes(BRANCH_META_FILE))
|
|
351
|
+
return 'live';
|
|
352
|
+
if (entries.some((name) => name.startsWith(`${BRANCH_META_FILE}.corrupt-`)))
|
|
353
|
+
return 'protected';
|
|
354
|
+
return 'residue';
|
|
355
|
+
}
|
|
356
|
+
const RENAME_BLOCKED = new Set(['EEXIST', 'ENOTEMPTY', 'ENOTDIR']);
|
|
357
|
+
/**
|
|
358
|
+
* Publish a staged workspace at its final path, under the branch's provisioning lock.
|
|
359
|
+
*
|
|
360
|
+
* A refused rename is inspected: branch.json there means another request won (`exists`);
|
|
361
|
+
* residue is quarantined and the rename retried once. ENOENT can be an NFS retransmission of a
|
|
362
|
+
* rename that already landed, so it counts as published when the final branch.json carries our
|
|
363
|
+
* `writeId`. The caller removes the staging directory on any outcome but `published`.
|
|
364
|
+
*/
|
|
365
|
+
export async function publishStaging(options) {
|
|
366
|
+
const { baseRoot, dirName, stagingPath } = options;
|
|
367
|
+
const finalPath = path.join(baseRoot, dirName);
|
|
368
|
+
const release = await lockBranchName(baseRoot, dirName);
|
|
369
|
+
try {
|
|
370
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
371
|
+
try {
|
|
372
|
+
await fs.rename(stagingPath, finalPath);
|
|
373
|
+
return 'published';
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (!isNodeError(err))
|
|
377
|
+
throw err;
|
|
378
|
+
if (err.code === 'ENOENT') {
|
|
379
|
+
const meta = await readBranchMetadataFile(finalPath).catch(() => null);
|
|
380
|
+
if (meta?.writeId !== undefined && meta.writeId === options.writeId)
|
|
381
|
+
return 'published';
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
if (!RENAME_BLOCKED.has(err.code ?? ''))
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
const blocking = await (options.inspectBlocked ?? inspectBlocking)(finalPath);
|
|
388
|
+
if (blocking === 'live')
|
|
389
|
+
return 'exists';
|
|
390
|
+
if (blocking === 'protected')
|
|
391
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
392
|
+
const result = await quarantineResidueAt(baseRoot, dirName, {
|
|
393
|
+
minQuietMs: CREATE_RESIDUE_MIN_QUIET_MS,
|
|
394
|
+
expectedRemoteUrl: options.expectedRemoteUrl,
|
|
395
|
+
});
|
|
396
|
+
if (result.kind !== 'kept')
|
|
397
|
+
continue;
|
|
398
|
+
if (result.reason === 'live')
|
|
399
|
+
return 'exists';
|
|
400
|
+
if (result.reason === 'corrupt') {
|
|
401
|
+
await readBranchMetadataFile(finalPath);
|
|
402
|
+
return 'exists';
|
|
403
|
+
}
|
|
404
|
+
if (result.reason === 'foreign')
|
|
405
|
+
throw new BranchDirOccupiedError(dirName);
|
|
406
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
407
|
+
}
|
|
408
|
+
throw new BranchProvisioningBusyError(dirName);
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
await release().catch((err) => {
|
|
412
|
+
canopyLogWarn(`[canopy] Failed to release the provisioning lock for ${dirName}:`, err);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function sweepByAge(root, rules, now) {
|
|
417
|
+
const entries = (await listDir(root)) ?? [];
|
|
418
|
+
const actions = [];
|
|
419
|
+
for (const name of entries) {
|
|
420
|
+
const rule = rules.find((candidate) => name.startsWith(candidate.prefix));
|
|
421
|
+
const stamp = rule ? parseDirStamp(name) : null;
|
|
422
|
+
if (!rule || !stamp || now - stamp.getTime() < rule.maxAgeMs)
|
|
423
|
+
continue;
|
|
424
|
+
try {
|
|
425
|
+
await fs.rm(path.join(root, name), {
|
|
426
|
+
recursive: true,
|
|
427
|
+
force: true,
|
|
428
|
+
maxRetries: 3,
|
|
429
|
+
retryDelay: 100,
|
|
430
|
+
});
|
|
431
|
+
actions.push({ name, action: 'removed' });
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
actions.push({ name, action: 'failed', detail: getErrorMessage(err) });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return actions;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* A `.repair-*` left by a process killed mid-quarantine goes back to its branch name when it
|
|
441
|
+
* holds branch.json and the name is free; anything else is trashed. The branch name comes from
|
|
442
|
+
* its branch.json, checked against the name's bounded prefix.
|
|
443
|
+
*/
|
|
444
|
+
async function recoverRepairDir(baseRoot, name) {
|
|
445
|
+
const repairPath = path.join(baseRoot, name);
|
|
446
|
+
const meta = await readBranchMetadataFile(repairPath).catch(() => null);
|
|
447
|
+
const dirName = meta ? sanitizeBranchName(meta.branch.name) : undefined;
|
|
448
|
+
if (dirName && name.startsWith(`${REPAIR_PREFIX}${shortDirName(dirName)}-`)) {
|
|
449
|
+
let release;
|
|
450
|
+
try {
|
|
451
|
+
release = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(dirName));
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
if (isNodeError(err) && err.code === 'ELOCKED') {
|
|
455
|
+
return { name, action: 'failed', detail: 'provisioning lock held; retrying next cycle' };
|
|
456
|
+
}
|
|
457
|
+
throw err;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const finalPath = path.join(baseRoot, dirName);
|
|
461
|
+
if ((await fs.lstat(finalPath).catch(() => null)) === null) {
|
|
462
|
+
await fs.rename(repairPath, finalPath);
|
|
463
|
+
return { name, action: 'restored', detail: dirName };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
await release().catch(() => { });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const trashName = `${TRASH_PREFIX}${name.slice(REPAIR_PREFIX.length)}`;
|
|
471
|
+
await fs.rename(repairPath, path.join(baseRoot, trashName));
|
|
472
|
+
return { name, action: 'trashed', detail: trashName };
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Clear what killed processes left beside the branch directories (`baseRoot`) and the settings
|
|
476
|
+
* workspace (`workspaceRoot`): stale `.prov-*` builds, `.deleting-*` directories whose `rm` never
|
|
477
|
+
* finished, `.repair-*` from an interrupted quarantine, and expired settings trash. Ages come
|
|
478
|
+
* from the stamp in each name.
|
|
479
|
+
*/
|
|
480
|
+
export async function sweepProvisioningLeftovers(baseRoot, workspaceRoot, now = Date.now()) {
|
|
481
|
+
const actions = await sweepByAge(baseRoot, [
|
|
482
|
+
{ prefix: STAGING_PREFIX, maxAgeMs: STAGING_MAX_AGE_MS },
|
|
483
|
+
{ prefix: DELETING_PREFIX, maxAgeMs: DELETING_MAX_AGE_MS },
|
|
484
|
+
], now);
|
|
485
|
+
for (const name of (await listDir(baseRoot)) ?? []) {
|
|
486
|
+
const stamp = name.startsWith(REPAIR_PREFIX) ? parseDirStamp(name) : null;
|
|
487
|
+
if (!stamp || now - stamp.getTime() < REPAIR_MAX_AGE_MS)
|
|
488
|
+
continue;
|
|
489
|
+
try {
|
|
490
|
+
actions.push(await recoverRepairDir(baseRoot, name));
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
actions.push({ name, action: 'failed', detail: getErrorMessage(err) });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
actions.push(...(await sweepByAge(workspaceRoot, [
|
|
497
|
+
{ prefix: `${STAGING_PREFIX}settings-`, maxAgeMs: STAGING_MAX_AGE_MS },
|
|
498
|
+
{ prefix: `${TRASH_PREFIX}settings-`, maxAgeMs: SETTINGS_TRASH_MAX_AGE_MS },
|
|
499
|
+
], now)));
|
|
500
|
+
return actions;
|
|
501
|
+
}
|