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
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* deletes every comment in the file, because comments live in neither the object nor that round
|
|
6
6
|
* trip. So these functions re-serialise onto the file's OWN parsed document: a node whose value
|
|
7
7
|
* did not change is left untouched, and an untouched node keeps its attached comments and its
|
|
8
|
-
* original quoting/block style. Only what actually changed is rewritten
|
|
8
|
+
* original quoting/block style. Only what actually changed is rewritten, and
|
|
9
|
+
* `yaml-source-splice.ts` writes it into the file's own text, so untouched lines keep their bytes
|
|
10
|
+
* wherever it can vouch for the result.
|
|
9
11
|
*
|
|
10
12
|
* The file gets no authority over its own content. The reconciler makes the document's key set
|
|
11
13
|
* match `data` exactly — a key the caller dropped disappears, a key the caller kept survives
|
|
@@ -14,9 +16,14 @@
|
|
|
14
16
|
* is a schema question, answered one layer up by `findUnknownKeys`
|
|
15
17
|
* (validation/entry-validator.ts) at the API boundary, not by a schema-blind serialiser.
|
|
16
18
|
*/
|
|
19
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
17
20
|
import matter from 'gray-matter';
|
|
18
|
-
import { isCollection, isMap, isNode, isScalar, isSeq, parseDocument, stringify as yamlStringify, } from 'yaml';
|
|
21
|
+
import { isCollection, isMap, isNode, isScalar, isSeq, parseDocument, Scalar, stringify as yamlStringify, } from 'yaml';
|
|
19
22
|
import { isBlockStructuralKey } from '../validation/block-structural-keys.js';
|
|
23
|
+
import { createDebugLogger } from './debug.js';
|
|
24
|
+
import { getErrorMessage } from './error.js';
|
|
25
|
+
import { snapshotDocument, spliceSource, withSourceLineEndings } from './yaml-source-splice.js';
|
|
26
|
+
const log = createDebugLogger({ prefix: 'ContentSerialize' });
|
|
20
27
|
/**
|
|
21
28
|
* True only for a PLAIN object — one that should be reconciled key-by-key against a YAML map.
|
|
22
29
|
*
|
|
@@ -187,13 +194,13 @@ function looksLikeSameItem(node, value) {
|
|
|
187
194
|
* Reconcile one slot of the document against the value that must occupy it, returning the node to
|
|
188
195
|
* put there. `existing` is the node in that slot, or null/undefined for a slot that did not exist.
|
|
189
196
|
*/
|
|
190
|
-
function reconcileNode(
|
|
197
|
+
function reconcileNode(ctx, existing, value) {
|
|
191
198
|
if (isMap(existing) && isPlainRecord(value)) {
|
|
192
|
-
reconcileMap(
|
|
199
|
+
reconcileMap(ctx, existing, value);
|
|
193
200
|
return existing;
|
|
194
201
|
}
|
|
195
202
|
if (isSeq(existing) && Array.isArray(value)) {
|
|
196
|
-
reconcileSeq(
|
|
203
|
+
reconcileSeq(ctx, existing, value);
|
|
197
204
|
return existing;
|
|
198
205
|
}
|
|
199
206
|
// Unchanged scalar: return the node itself, untouched. This is the case that preserves comments
|
|
@@ -203,7 +210,16 @@ function reconcileNode(doc, existing, value) {
|
|
|
203
210
|
// Changed, or a shape change (scalar <-> collection). A fresh node rather than mutating
|
|
204
211
|
// `scalar.value` in place, which would keep the old node's representation and emit `'42'` where
|
|
205
212
|
// the number 42 was meant.
|
|
206
|
-
const fresh = doc.createNode(value);
|
|
213
|
+
const fresh = ctx.doc.createNode(value);
|
|
214
|
+
if (isNode(existing))
|
|
215
|
+
ctx.replaced.set(fresh, existing);
|
|
216
|
+
if (isScalar(existing) && isScalar(fresh) && typeof value === 'string') {
|
|
217
|
+
const style = carriedStyle(existing, value);
|
|
218
|
+
if (style !== undefined) {
|
|
219
|
+
fresh.type = style;
|
|
220
|
+
ctx.restyled.push(fresh);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
207
223
|
// Comments move with a changed VALUE, but not off a replaced STRUCTURE. `yaml` attaches a
|
|
208
224
|
// comment written above a collection's first entry to the collection node itself, so that
|
|
209
225
|
// node's comments are about its innards; carrying them onto whatever replaces the collection
|
|
@@ -214,12 +230,33 @@ function reconcileNode(doc, existing, value) {
|
|
|
214
230
|
carryComments(existing, fresh);
|
|
215
231
|
return fresh;
|
|
216
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* The style a changed string inherits from the string it replaces: its quoting, or its block
|
|
235
|
+
* style (`>-`, `|`) unless the new value has no content or leads with whitespace, which `yaml`
|
|
236
|
+
* can print lossily in a block (`" "` reads back as `""`). Never `PLAIN`: forcing it stops `yaml` choosing a
|
|
237
|
+
* block for a multi-line value, and some (`Requirements:\nBring a laptop`) then print as
|
|
238
|
+
* unparseable YAML. An unset style is plain where plain is safe.
|
|
239
|
+
*/
|
|
240
|
+
function carriedStyle(existing, value) {
|
|
241
|
+
if (typeof existing.value !== 'string')
|
|
242
|
+
return undefined;
|
|
243
|
+
switch (existing.type) {
|
|
244
|
+
case Scalar.QUOTE_DOUBLE:
|
|
245
|
+
case Scalar.QUOTE_SINGLE:
|
|
246
|
+
return existing.type;
|
|
247
|
+
case Scalar.BLOCK_FOLDED:
|
|
248
|
+
case Scalar.BLOCK_LITERAL:
|
|
249
|
+
return /\S/.test(value) && !/^\s/.test(value) ? existing.type : undefined;
|
|
250
|
+
default:
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
217
254
|
/**
|
|
218
255
|
* Make a map's key set match `value` exactly. Retained pairs are reconciled in place, so their key
|
|
219
256
|
* order and the comments attached to their keys survive; keys new to `value` are appended in
|
|
220
257
|
* `value` order, keeping a save's diff down to the lines that actually changed.
|
|
221
258
|
*/
|
|
222
|
-
function reconcileMap(
|
|
259
|
+
function reconcileMap(ctx, map, value) {
|
|
223
260
|
// An explicitly-undefined key is NOT a key: `Object.keys` reports it but `JSON.stringify` and
|
|
224
261
|
// `yaml.stringify` omit it, so without this filter a key present on disk and set to `undefined`
|
|
225
262
|
// in the payload is rewritten as `key: null` here while the create path drops it. Same rule on
|
|
@@ -234,14 +271,14 @@ function reconcileMap(doc, map, value) {
|
|
|
234
271
|
if (key === undefined || !wanted.has(key) || seen.has(key))
|
|
235
272
|
continue;
|
|
236
273
|
seen.add(key);
|
|
237
|
-
pair.value = reconcileNode(
|
|
274
|
+
pair.value = reconcileNode(ctx, pair.value, value[key]);
|
|
238
275
|
retained.push(pair);
|
|
239
276
|
}
|
|
240
277
|
map.items = retained;
|
|
241
278
|
for (const key of Object.keys(value)) {
|
|
242
279
|
if (seen.has(key) || !wanted.has(key))
|
|
243
280
|
continue;
|
|
244
|
-
map.set(doc.createNode(key), doc.createNode(value[key]));
|
|
281
|
+
map.set(ctx.doc.createNode(key), ctx.doc.createNode(value[key]));
|
|
245
282
|
}
|
|
246
283
|
}
|
|
247
284
|
/**
|
|
@@ -261,7 +298,7 @@ function reconcileMap(doc, map, value) {
|
|
|
261
298
|
* replacement lands on the index it replaced.
|
|
262
299
|
* 3. **Otherwise a fresh node, with no comments.**
|
|
263
300
|
*/
|
|
264
|
-
function reconcileSeq(
|
|
301
|
+
function reconcileSeq(ctx, seq, value) {
|
|
265
302
|
const oldItems = seq.items;
|
|
266
303
|
// Old indices by identity, in order, so equal items are consumed first-come-first-served.
|
|
267
304
|
const byIdentity = new Map();
|
|
@@ -298,28 +335,67 @@ function reconcileSeq(doc, seq, value) {
|
|
|
298
335
|
// unclaimed?". Each iteration owns a distinct index, so a candidate cannot be taken twice.
|
|
299
336
|
const candidate = index < oldItems.length && !consumed.has(index) ? oldItems[index] : undefined;
|
|
300
337
|
const sameItem = candidate !== undefined && looksLikeSameItem(candidate, item) ? candidate : undefined;
|
|
301
|
-
return reconcileNode(
|
|
338
|
+
return reconcileNode(ctx, sameItem, item);
|
|
302
339
|
});
|
|
303
340
|
}
|
|
304
|
-
/**
|
|
305
|
-
|
|
306
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Re-serialise `data` onto the YAML text `raw`, or undefined when `raw` does not parse or the
|
|
343
|
+
* reconciled document cannot be printed so that it reads back as its own data.
|
|
344
|
+
*
|
|
345
|
+
* The reconciler decides WHAT changes; {@link spliceSource} then writes those changes into the
|
|
346
|
+
* source text, so untouched lines keep the author's own folding and spacing. When it cannot
|
|
347
|
+
* vouch for a splice, the result is the reconciled document's own `toString()`, which is itself
|
|
348
|
+
* kept only if it parses back to the same data: a carried style that `yaml` prints lossily is
|
|
349
|
+
* dropped and the document re-printed, and failing that the caller writes a plain stringify.
|
|
350
|
+
* The worst case is a re-folded file, or one without its comments; never different data.
|
|
351
|
+
* `rootAtColumnZero` refuses a splice whose first line is indented, for frontmatter, whose
|
|
352
|
+
* framing trims the first line's indentation.
|
|
353
|
+
*/
|
|
354
|
+
function reconcileYamlSource(raw, data, rootAtColumnZero = false) {
|
|
355
|
+
const doc = parseDocument(raw);
|
|
356
|
+
if (doc.errors.length > 0)
|
|
357
|
+
return undefined;
|
|
358
|
+
const snapshot = snapshotDocument(doc);
|
|
359
|
+
const ctx = { doc, replaced: new WeakMap(), restyled: [] };
|
|
360
|
+
doc.contents = reconcileNode(ctx, doc.contents, data);
|
|
361
|
+
let reconciled = withSourceLineEndings(doc.toString(), raw);
|
|
362
|
+
if (!readsBackAs(reconciled, doc)) {
|
|
363
|
+
for (const scalar of ctx.restyled)
|
|
364
|
+
scalar.type = undefined;
|
|
365
|
+
reconciled = withSourceLineEndings(doc.toString(), raw);
|
|
366
|
+
if (!readsBackAs(reconciled, doc))
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const spliced = spliceSource(raw, doc, snapshot, ctx.replaced, reconciled);
|
|
371
|
+
if (spliced !== undefined && !(rootAtColumnZero && /^\s*[ \t]\S/.test(spliced))) {
|
|
372
|
+
return spliced;
|
|
373
|
+
}
|
|
374
|
+
log.debug('content-serialize', 'source splice not used; writing as yaml prints it');
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
log.debug('content-serialize', 'source splice failed; writing as yaml prints it', {
|
|
378
|
+
error: getErrorMessage(err),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return reconciled;
|
|
382
|
+
}
|
|
383
|
+
function readsBackAs(text, doc) {
|
|
384
|
+
const reparsed = parseDocument(text);
|
|
385
|
+
return reparsed.errors.length === 0 && isDeepStrictEqual(reparsed.toJS(), doc.toJS());
|
|
307
386
|
}
|
|
308
387
|
/**
|
|
309
388
|
* Serialise entry data as a YAML file, carrying the comments of `existingRaw` through.
|
|
310
389
|
*
|
|
311
390
|
* Falls back to a plain stringify — byte-identical to serialising without preservation — when
|
|
312
391
|
* there is nothing to preserve (a new file) or nothing trustworthy to preserve (the bytes on disk
|
|
313
|
-
* do not parse
|
|
392
|
+
* do not parse, or the reconciled document does not read back as its own data). A save must not
|
|
393
|
+
* fail because the previous content was malformed.
|
|
314
394
|
*/
|
|
315
395
|
export function serializeYaml(data, existingRaw) {
|
|
316
396
|
if (existingRaw === undefined)
|
|
317
397
|
return yamlStringify(data);
|
|
318
|
-
|
|
319
|
-
if (doc.errors.length > 0)
|
|
320
|
-
return yamlStringify(data);
|
|
321
|
-
applyDataToDocument(doc, data);
|
|
322
|
-
return doc.toString();
|
|
398
|
+
return reconcileYamlSource(existingRaw, data) ?? yamlStringify(data);
|
|
323
399
|
}
|
|
324
400
|
/**
|
|
325
401
|
* Split a file into its raw frontmatter string, or undefined when there is nothing usable. Two
|
|
@@ -362,11 +438,9 @@ export function serializeFrontmatter(body, data, existingRaw) {
|
|
|
362
438
|
const existingFrontmatter = extractRawFrontmatter(existingRaw);
|
|
363
439
|
if (existingFrontmatter === undefined)
|
|
364
440
|
return matter.stringify(body, data);
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
441
|
+
const reconciled = reconcileYamlSource(existingFrontmatter, data, true);
|
|
442
|
+
if (reconciled === undefined)
|
|
367
443
|
return matter.stringify(body, data);
|
|
368
|
-
applyDataToDocument(doc, data);
|
|
369
|
-
const reconciled = doc.toString();
|
|
370
444
|
return matter.stringify(body, data, {
|
|
371
445
|
engines: {
|
|
372
446
|
yaml: {
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
*
|
|
22
22
|
* The marker lives under `{branchRoot}/.canopy-meta` and the lock anchors on that marker path like
|
|
23
23
|
* every other lock (see provisioning-lock.ts), so it can never alias the branch's provisioning
|
|
24
|
-
* lock
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* lock. The worker's rebase and base-branch refresh hold both, provisioning outside this one, and
|
|
25
|
+
* take each try-only; the global order is docs/concurrency.md's "Lock acquisition order".
|
|
26
|
+
* `.canopy-meta/` is git-excluded in every branch clone
|
|
27
|
+
* (`ensureGitExclude`), so the lock directory cannot dirty the tree or be staged.
|
|
27
28
|
*
|
|
28
29
|
* Mutual exclusion is not proven: on EFS a stale cached mtime lets a waiter take over a live lock,
|
|
29
30
|
* leaving two unsynchronized writers -- the unlocked behaviour, so still a strict improvement.
|
|
@@ -50,14 +51,24 @@ export declare const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
|
|
|
50
51
|
* Thrown when the bounded wait expires with the branch's content lock still held. Retriable --
|
|
51
52
|
* callers translate it into a 409 with a message that says so.
|
|
52
53
|
*
|
|
53
|
-
* The message says "syncing OR another save" because BOTH produce it: the lock is taken by
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* The message says "syncing OR another save" because BOTH produce it: the lock is taken by every
|
|
55
|
+
* working-tree mutator (docs/concurrency.md, "Who takes it") as well as by the worker's rebase,
|
|
56
|
+
* so naming only the rebase would claim more than is known. `api/content.ts`
|
|
56
57
|
* routes this error ahead of the generic conflict so the editor sees this wording, making it
|
|
57
58
|
* load-bearing rather than cosmetic.
|
|
58
59
|
*/
|
|
59
60
|
export declare class ContentWriteLockBusyError extends Error {
|
|
60
|
-
|
|
61
|
+
/**
|
|
62
|
+
* `'not-run'`: the lock was never acquired, so nothing happened. `'unknown'`: the work ran
|
|
63
|
+
* but the lock was lost during it (see {@link withContentWriteLock}).
|
|
64
|
+
*/
|
|
65
|
+
readonly outcome: 'not-run' | 'unknown';
|
|
66
|
+
constructor(message?: string,
|
|
67
|
+
/**
|
|
68
|
+
* `'not-run'`: the lock was never acquired, so nothing happened. `'unknown'`: the work ran
|
|
69
|
+
* but the lock was lost during it (see {@link withContentWriteLock}).
|
|
70
|
+
*/
|
|
71
|
+
outcome?: 'not-run' | 'unknown');
|
|
61
72
|
}
|
|
62
73
|
/**
|
|
63
74
|
* Acquire the branch's content-write lock WITHOUT waiting, for the worker's rebase loop, which
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
*
|
|
22
22
|
* The marker lives under `{branchRoot}/.canopy-meta` and the lock anchors on that marker path like
|
|
23
23
|
* every other lock (see provisioning-lock.ts), so it can never alias the branch's provisioning
|
|
24
|
-
* lock
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* lock. The worker's rebase and base-branch refresh hold both, provisioning outside this one, and
|
|
25
|
+
* take each try-only; the global order is docs/concurrency.md's "Lock acquisition order".
|
|
26
|
+
* `.canopy-meta/` is git-excluded in every branch clone
|
|
27
|
+
* (`ensureGitExclude`), so the lock directory cannot dirty the tree or be staged.
|
|
27
28
|
*
|
|
28
29
|
* Mutual exclusion is not proven: on EFS a stale cached mtime lets a waiter take over a live lock,
|
|
29
30
|
* leaving two unsynchronized writers -- the unlocked behaviour, so still a strict improvement.
|
|
@@ -57,15 +58,21 @@ export const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
|
|
|
57
58
|
* Thrown when the bounded wait expires with the branch's content lock still held. Retriable --
|
|
58
59
|
* callers translate it into a 409 with a message that says so.
|
|
59
60
|
*
|
|
60
|
-
* The message says "syncing OR another save" because BOTH produce it: the lock is taken by
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* The message says "syncing OR another save" because BOTH produce it: the lock is taken by every
|
|
62
|
+
* working-tree mutator (docs/concurrency.md, "Who takes it") as well as by the worker's rebase,
|
|
63
|
+
* so naming only the rebase would claim more than is known. `api/content.ts`
|
|
63
64
|
* routes this error ahead of the generic conflict so the editor sees this wording, making it
|
|
64
65
|
* load-bearing rather than cosmetic.
|
|
65
66
|
*/
|
|
66
67
|
export class ContentWriteLockBusyError extends Error {
|
|
67
|
-
constructor(message = 'This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.'
|
|
68
|
+
constructor(message = 'This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.',
|
|
69
|
+
/**
|
|
70
|
+
* `'not-run'`: the lock was never acquired, so nothing happened. `'unknown'`: the work ran
|
|
71
|
+
* but the lock was lost during it (see {@link withContentWriteLock}).
|
|
72
|
+
*/
|
|
73
|
+
outcome = 'not-run') {
|
|
68
74
|
super(message);
|
|
75
|
+
this.outcome = outcome;
|
|
69
76
|
this.name = 'ContentWriteLockBusyError';
|
|
70
77
|
}
|
|
71
78
|
}
|
|
@@ -77,12 +84,14 @@ function lockTargetDir(branchRoot) {
|
|
|
77
84
|
const sleep = (ms) => new Promise((resolve) => {
|
|
78
85
|
setTimeout(resolve, ms);
|
|
79
86
|
});
|
|
87
|
+
/** Below provisioning's, so a crashed worker blocks saves for at most 30s (docs/concurrency.md). */
|
|
88
|
+
const CONTENT_WRITE_LOCK_STALE_MS = 30_000;
|
|
80
89
|
/**
|
|
81
90
|
* Acquire the branch's content-write lock WITHOUT waiting, for the worker's rebase loop, which
|
|
82
91
|
* skips the branch and retries next cycle. Throws with `code === 'ELOCKED'` on a live holder.
|
|
83
92
|
*/
|
|
84
93
|
export function tryAcquireContentWriteLock(branchRoot, onCompromised) {
|
|
85
|
-
return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
|
|
94
|
+
return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS, false);
|
|
86
95
|
}
|
|
87
96
|
/**
|
|
88
97
|
* Acquire the branch's content-write lock with a short bounded wait.
|
|
@@ -136,7 +145,7 @@ export async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONT
|
|
|
136
145
|
// disk and only the proof of exclusivity is lost. "Reload, then decide" rather than "retry",
|
|
137
146
|
// which would resend a now-stale expectedVersion and bounce off the caller's own landed write
|
|
138
147
|
// as a phantom editor collision.
|
|
139
|
-
throw new ContentWriteLockBusyError('This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.');
|
|
148
|
+
throw new ContentWriteLockBusyError('This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.', 'unknown');
|
|
140
149
|
}
|
|
141
150
|
return result;
|
|
142
151
|
}
|
package/dist/utils/debug.d.ts
CHANGED
|
@@ -26,8 +26,16 @@ export declare class DebugLogger {
|
|
|
26
26
|
info(category: string, message: string, data?: unknown): void;
|
|
27
27
|
warn(category: string, message: string, data?: unknown): void;
|
|
28
28
|
error(category: string, message: string, data?: unknown): void;
|
|
29
|
+
/**
|
|
30
|
+
* Keyed by label alone, so two overlapping timers with one label overwrite each other;
|
|
31
|
+
* anything that can run concurrently uses {@link timed}.
|
|
32
|
+
*/
|
|
29
33
|
time(label: string): void;
|
|
30
34
|
timeEnd(category: string, label: string): number | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
|
|
37
|
+
* call, so concurrent spans with the same label on one logger never overwrite each other.
|
|
38
|
+
*/
|
|
31
39
|
timed<T>(category: string, label: string, fn: () => Promise<T>): Promise<T>;
|
|
32
40
|
}
|
|
33
41
|
export declare function createDebugLogger(options?: DebugOptions): DebugLogger;
|
package/dist/utils/debug.js
CHANGED
|
@@ -55,6 +55,10 @@ export class DebugLogger {
|
|
|
55
55
|
throw new Error(errorMsg);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Keyed by label alone, so two overlapping timers with one label overwrite each other;
|
|
60
|
+
* anything that can run concurrently uses {@link timed}.
|
|
61
|
+
*/
|
|
58
62
|
time(label) {
|
|
59
63
|
this.timers.set(label, Date.now());
|
|
60
64
|
}
|
|
@@ -69,13 +73,17 @@ export class DebugLogger {
|
|
|
69
73
|
this.debug(category, `${label} completed`, { durationMs: duration });
|
|
70
74
|
return duration;
|
|
71
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
|
|
78
|
+
* call, so concurrent spans with the same label on one logger never overwrite each other.
|
|
79
|
+
*/
|
|
72
80
|
async timed(category, label, fn) {
|
|
73
|
-
|
|
81
|
+
const start = Date.now();
|
|
74
82
|
try {
|
|
75
83
|
return await fn();
|
|
76
84
|
}
|
|
77
85
|
finally {
|
|
78
|
-
this.
|
|
86
|
+
this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
}
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SimpleGit } from 'simple-git';
|
|
1
2
|
import type { OperatingMode } from '../operating-mode/index.js';
|
|
2
3
|
/**
|
|
3
4
|
* Whether a git remote URL points at a network location rather than a local filesystem path. Only
|
|
@@ -39,6 +40,15 @@ export declare function isNonFastForwardRejection(message: string): boolean;
|
|
|
39
40
|
* surfacing as the permanent, human-actionable state it is. Same locale caveat as above.
|
|
40
41
|
*/
|
|
41
42
|
export declare function isStaleLeaseRejection(message: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The workflow file named by GitHub's refusal of a push that would add workflow content the
|
|
45
|
+
* credential lacks the workflows permission for, or null if the message is not that refusal.
|
|
46
|
+
*
|
|
47
|
+
* GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
|
|
48
|
+
* rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
|
|
49
|
+
* worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
|
|
50
|
+
*/
|
|
51
|
+
export declare function workflowPushRefusalFile(message: string): string | null;
|
|
42
52
|
/**
|
|
43
53
|
* Whether a `git fetch <remote> <branch>` failure is specifically "that ref doesn't exist on the
|
|
44
54
|
* remote" -- the ONLY benign fetch outcome, meaning the branch has never been pushed.
|
|
@@ -50,6 +60,28 @@ export declare function isStaleLeaseRejection(message: string): boolean;
|
|
|
50
60
|
* caveat as the predicates above.
|
|
51
61
|
*/
|
|
52
62
|
export declare function isMissingRemoteRefFailure(message: string): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* The directory, at every workspace root, holding canopycms's own per-workspace state: branch
|
|
65
|
+
* metadata, comments, generation markers and lock markers. None of it is content, so it never
|
|
66
|
+
* belongs in a commit.
|
|
67
|
+
*/
|
|
68
|
+
export declare const CANOPY_META_DIR = ".canopy-meta";
|
|
69
|
+
/**
|
|
70
|
+
* Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
|
|
71
|
+
* or lies under it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isCanopyInternalPath(repoRelativePath: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Stage every working-tree change (additions, edits, deletions) except anything under
|
|
76
|
+
* {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
|
|
77
|
+
* unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
|
|
78
|
+
* the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
|
|
79
|
+
*
|
|
80
|
+
* `--sparse`: in a sparse clone a plain `add -A` fails on any file present outside the cone.
|
|
81
|
+
* With it, such a file is staged like any other, and a tracked file absent only because it is
|
|
82
|
+
* outside the cone is still not staged as a deletion. A no-op in a full clone.
|
|
83
|
+
*/
|
|
84
|
+
export declare function stageAllExceptCanopyState(git: SimpleGit): Promise<void>;
|
|
53
85
|
/**
|
|
54
86
|
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
|
|
55
87
|
* backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
|
package/dist/utils/git.js
CHANGED
|
@@ -88,6 +88,21 @@ const STALE_LEASE_REASON = 'stale info';
|
|
|
88
88
|
export function isStaleLeaseRejection(message) {
|
|
89
89
|
return message.includes(REJECTED_MARKER) && message.includes(STALE_LEASE_REASON);
|
|
90
90
|
}
|
|
91
|
+
// GitHub's reason text when a push would introduce workflow content the credential may not write.
|
|
92
|
+
// It names the credential kind ("a GitHub App", "an OAuth App", ...) and then the file.
|
|
93
|
+
// Both classes stop at a newline, so the match stays on the one status line that carries it.
|
|
94
|
+
const WORKFLOW_REFUSAL_PATTERN = /refusing to allow an? [^`\n]+? to create or update workflow `([^`\n]+)`/;
|
|
95
|
+
/**
|
|
96
|
+
* The workflow file named by GitHub's refusal of a push that would add workflow content the
|
|
97
|
+
* credential lacks the workflows permission for, or null if the message is not that refusal.
|
|
98
|
+
*
|
|
99
|
+
* GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
|
|
100
|
+
* rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
|
|
101
|
+
* worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
|
|
102
|
+
*/
|
|
103
|
+
export function workflowPushRefusalFile(message) {
|
|
104
|
+
return WORKFLOW_REFUSAL_PATTERN.exec(message)?.[1] ?? null;
|
|
105
|
+
}
|
|
91
106
|
// git's message when `git fetch <remote> <branch>` names a ref the remote does not have. Both
|
|
92
107
|
// spellings occur: modern git prints the lowercase form, older versions and some transports
|
|
93
108
|
// capitalize it.
|
|
@@ -135,6 +150,33 @@ async function resolveGitDir(repoPath) {
|
|
|
135
150
|
return null;
|
|
136
151
|
}
|
|
137
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* The directory, at every workspace root, holding canopycms's own per-workspace state: branch
|
|
155
|
+
* metadata, comments, generation markers and lock markers. None of it is content, so it never
|
|
156
|
+
* belongs in a commit.
|
|
157
|
+
*/
|
|
158
|
+
export const CANOPY_META_DIR = '.canopy-meta';
|
|
159
|
+
/**
|
|
160
|
+
* Whether a repo-relative path from `git status` / `git ls-files` is {@link CANOPY_META_DIR}
|
|
161
|
+
* or lies under it.
|
|
162
|
+
*/
|
|
163
|
+
export function isCanopyInternalPath(repoRelativePath) {
|
|
164
|
+
return repoRelativePath === CANOPY_META_DIR || repoRelativePath.startsWith(`${CANOPY_META_DIR}/`);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Stage every working-tree change (additions, edits, deletions) except anything under
|
|
168
|
+
* {@link CANOPY_META_DIR}, including files an adopter committed there by mistake. Stage-all then
|
|
169
|
+
* unstage, because git rejects `:(exclude)` pathspecs (six spellings tried) with "paths are ignored" when
|
|
170
|
+
* the excluded directory is itself ignored, which `.git/info/exclude` makes it in every clone.
|
|
171
|
+
*
|
|
172
|
+
* `--sparse`: in a sparse clone a plain `add -A` fails on any file present outside the cone.
|
|
173
|
+
* With it, such a file is staged like any other, and a tracked file absent only because it is
|
|
174
|
+
* outside the cone is still not staged as a deletion. A no-op in a full clone.
|
|
175
|
+
*/
|
|
176
|
+
export async function stageAllExceptCanopyState(git) {
|
|
177
|
+
await git.raw(['add', '-A', '--sparse']);
|
|
178
|
+
await git.raw(['reset', '-q', '--', CANOPY_META_DIR]);
|
|
179
|
+
}
|
|
138
180
|
/**
|
|
139
181
|
* Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge` (interactive/merge
|
|
140
182
|
* backend) or `rebase-apply` (am backend) state directory git leaves when a rebase stops for
|
|
@@ -158,8 +158,16 @@ export async function withOccRetry(operation, options) {
|
|
|
158
158
|
* legitimately take many seconds.
|
|
159
159
|
*/
|
|
160
160
|
export async function withOccFileLock(filePath, fn) {
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
// The lock's own directory only, never its ancestors: a waiter must not recreate a branch root
|
|
162
|
+
// that a delete removed under it (the same rule as the ENOENT case in the loop below).
|
|
163
|
+
try {
|
|
164
|
+
await fs.mkdir(path.dirname(filePath));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (!isNodeError(err) || err.code !== 'EEXIST') {
|
|
168
|
+
throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
163
171
|
// Acquisition retries are OUR loop, not proper-lockfile's built-in
|
|
164
172
|
// `retries`: the built-in loop retries blindly on ANY error, so a waiter
|
|
165
173
|
// whose target directory was deleted mid-poll (deleteBranch's rm removing
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace where step lines go; no argument restores `canopyLog`. The test
|
|
3
|
+
* setup silences them, since nearly every suite provisions a workspace.
|
|
4
|
+
* @internal Exported for tests.
|
|
5
|
+
*/
|
|
6
|
+
export declare function setProvisionLogSink(next?: (line: string) => void): void;
|
|
7
|
+
/**
|
|
8
|
+
* Per-step timing for one workspace provisioning, printed unconditionally
|
|
9
|
+
* (never behind `CANOPYCMS_DEBUG`). A request killed mid-step, such as a Lambda
|
|
10
|
+
* at its timeout, never reaches its own error handling, so each step prints a
|
|
11
|
+
* `start` line and a `done` line: the last line in the log names the step that
|
|
12
|
+
* was running.
|
|
13
|
+
*
|
|
14
|
+
* Lines read `[canopy] provision id=<id> dir=<dir> step=<step> start|done ms=<n>|failed ms=<n>`,
|
|
15
|
+
* then one `outcome=<outcome> total=<ms> <step>=<ms> …` summary.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ProvisionLog {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
private readonly startedAt;
|
|
20
|
+
private readonly durations;
|
|
21
|
+
private readonly prefix;
|
|
22
|
+
constructor(dir: string);
|
|
23
|
+
step<T>(name: string, run: () => Promise<T>): Promise<T>;
|
|
24
|
+
finish(outcome: string): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { canopyLog } from './logger.js';
|
|
3
|
+
const defaultSink = (line) => canopyLog(line);
|
|
4
|
+
let sink = defaultSink;
|
|
5
|
+
/**
|
|
6
|
+
* Replace where step lines go; no argument restores `canopyLog`. The test
|
|
7
|
+
* setup silences them, since nearly every suite provisions a workspace.
|
|
8
|
+
* @internal Exported for tests.
|
|
9
|
+
*/
|
|
10
|
+
export function setProvisionLogSink(next) {
|
|
11
|
+
sink = next ?? defaultSink;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Per-step timing for one workspace provisioning, printed unconditionally
|
|
15
|
+
* (never behind `CANOPYCMS_DEBUG`). A request killed mid-step, such as a Lambda
|
|
16
|
+
* at its timeout, never reaches its own error handling, so each step prints a
|
|
17
|
+
* `start` line and a `done` line: the last line in the log names the step that
|
|
18
|
+
* was running.
|
|
19
|
+
*
|
|
20
|
+
* Lines read `[canopy] provision id=<id> dir=<dir> step=<step> start|done ms=<n>|failed ms=<n>`,
|
|
21
|
+
* then one `outcome=<outcome> total=<ms> <step>=<ms> …` summary.
|
|
22
|
+
*/
|
|
23
|
+
export class ProvisionLog {
|
|
24
|
+
constructor(dir) {
|
|
25
|
+
this.id = randomBytes(3).toString('hex');
|
|
26
|
+
this.startedAt = Date.now();
|
|
27
|
+
this.durations = [];
|
|
28
|
+
this.prefix = `[canopy] provision id=${this.id} dir=${dir}`;
|
|
29
|
+
}
|
|
30
|
+
async step(name, run) {
|
|
31
|
+
sink(`${this.prefix} step=${name} start`);
|
|
32
|
+
const stepStartedAt = Date.now();
|
|
33
|
+
let state = 'failed';
|
|
34
|
+
try {
|
|
35
|
+
const result = await run();
|
|
36
|
+
state = 'done';
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
const ms = Date.now() - stepStartedAt;
|
|
41
|
+
this.durations.push(`${name}=${ms}`);
|
|
42
|
+
sink(`${this.prefix} step=${name} ${state} ms=${ms}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
finish(outcome) {
|
|
46
|
+
const total = Date.now() - this.startedAt;
|
|
47
|
+
sink(`${this.prefix} outcome=${outcome} total=${total} ${this.durations.join(' ')}`.trim());
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
* inside the refresh timer, so a throw is an uncaught exception that kills the process.
|
|
10
10
|
*/
|
|
11
11
|
export type OnLockCompromised = (err: Error) => void;
|
|
12
|
+
/**
|
|
13
|
+
* When an acquirer may take a provisioning marker over. Staleness is read from the marker's mtime,
|
|
14
|
+
* which EFS serves from the NFS attribute cache for up to 60s, so a live holder's 15s refreshes
|
|
15
|
+
* can look 60s late; one threshold above that for every acquirer means none reaps a live hold.
|
|
16
|
+
*/
|
|
17
|
+
export declare const PROVISIONING_LOCK_STALE_MS = 90000;
|
|
18
|
+
/**
|
|
19
|
+
* The provisioning lock's name for the branch workspace directory `dirName`, held in that
|
|
20
|
+
* directory's parent. Every party to a branch workspace's provisioning names it through this:
|
|
21
|
+
* the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
|
|
22
|
+
* while it refreshes or rebases.
|
|
23
|
+
*/
|
|
24
|
+
export declare function branchProvisioningLockName(dirName: string): string;
|
|
12
25
|
/**
|
|
13
26
|
* Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
|
|
14
27
|
* always call it in a `finally`.
|
|
@@ -27,12 +40,29 @@ export declare function acquireProvisioningLock(lockTargetDir: string, lockName:
|
|
|
27
40
|
* request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
|
|
28
41
|
* budget waits minutes for a live provisioner; an admin request must fail fast instead.
|
|
29
42
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
43
|
+
* Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
|
|
44
|
+
* over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
|
|
45
|
+
* caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
|
|
46
|
+
* before an admin purge/repair proceeds.
|
|
34
47
|
*
|
|
35
48
|
* Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
|
|
36
49
|
* translate that into a 409.
|
|
50
|
+
*
|
|
51
|
+
* @param staleMs how old the marker must look before this caller takes it over; defaults to
|
|
52
|
+
* {@link PROVISIONING_LOCK_STALE_MS}
|
|
53
|
+
* @param createParents whether a missing parent of `lockTargetDir` is created. A lock inside a
|
|
54
|
+
* branch passes false: a waiter must never recreate a branch root a delete removed, so a missing
|
|
55
|
+
* parent fails with ENOENT instead.
|
|
56
|
+
*/
|
|
57
|
+
export declare function tryAcquireProvisioningLock(lockTargetDir: string, lockName: string, onCompromised?: OnLockCompromised, staleMs?: number, createParents?: boolean): Promise<() => Promise<void>>;
|
|
58
|
+
/**
|
|
59
|
+
* {@link tryAcquireProvisioningLock} retried for at most `waitMs`, for a caller whose own hold is
|
|
60
|
+
* milliseconds long (publishing a staged branch workspace). A dead holder's marker stays live for
|
|
61
|
+
* {@link PROVISIONING_LOCK_STALE_MS}, so waiting it out from a request would spend the request's
|
|
62
|
+
* whole timeout; this gives up instead.
|
|
63
|
+
*
|
|
64
|
+
* Retries only on `ELOCKED`, like the content-write lock's bounded wait.
|
|
65
|
+
*
|
|
66
|
+
* @throws the last `ELOCKED` error once the budget is spent
|
|
37
67
|
*/
|
|
38
|
-
export declare function
|
|
68
|
+
export declare function acquireProvisioningLockWithin(lockTargetDir: string, lockName: string, waitMs: number, onCompromised?: OnLockCompromised): Promise<() => Promise<void>>;
|