canopycms 0.0.54 → 0.0.56
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/dist/api/assets.d.ts +14 -2
- package/dist/api/assets.d.ts.map +1 -1
- package/dist/api/assets.js +14 -3
- package/dist/api/assets.js.map +1 -1
- package/dist/api/branch-status.d.ts.map +1 -1
- package/dist/api/branch-status.js +8 -2
- package/dist/api/branch-status.js.map +1 -1
- package/dist/api/client.d.ts +2 -2
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +29 -14
- package/dist/api/client.js.map +1 -1
- package/dist/api/content.d.ts +2 -2
- package/dist/api/content.d.ts.map +1 -1
- package/dist/api/content.js +160 -26
- package/dist/api/content.js.map +1 -1
- package/dist/api/entries.d.ts.map +1 -1
- package/dist/api/entries.js +8 -4
- package/dist/api/entries.js.map +1 -1
- package/dist/api/github-sync.d.ts.map +1 -1
- package/dist/api/github-sync.js +45 -7
- package/dist/api/github-sync.js.map +1 -1
- package/dist/api/groups.d.ts.map +1 -1
- package/dist/api/groups.js +15 -5
- package/dist/api/groups.js.map +1 -1
- package/dist/api/permissions.d.ts +6 -6
- package/dist/api/permissions.d.ts.map +1 -1
- package/dist/api/permissions.js +24 -9
- package/dist/api/permissions.js.map +1 -1
- package/dist/api/request-body-hash.d.ts +33 -0
- package/dist/api/request-body-hash.d.ts.map +1 -0
- package/dist/api/request-body-hash.js +43 -0
- package/dist/api/request-body-hash.js.map +1 -0
- package/dist/api/resolve-references.d.ts.map +1 -1
- package/dist/api/resolve-references.js +8 -1
- package/dist/api/resolve-references.js.map +1 -1
- package/dist/api/schema.js +9 -9
- package/dist/api/schema.js.map +1 -1
- package/dist/api/settings-helpers.d.ts +14 -1
- package/dist/api/settings-helpers.d.ts.map +1 -1
- package/dist/api/settings-helpers.js +11 -2
- package/dist/api/settings-helpers.js.map +1 -1
- package/dist/api/types.d.ts +10 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/auth/caching-auth-plugin.d.ts +8 -1
- package/dist/auth/caching-auth-plugin.d.ts.map +1 -1
- package/dist/auth/caching-auth-plugin.js +6 -1
- package/dist/auth/caching-auth-plugin.js.map +1 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/plugin.d.ts +35 -0
- package/dist/auth/plugin.d.ts.map +1 -1
- package/dist/auth/plugin.js +27 -1
- package/dist/auth/plugin.js.map +1 -1
- package/dist/authorization/helpers.d.ts +16 -0
- package/dist/authorization/helpers.d.ts.map +1 -1
- package/dist/authorization/helpers.js +18 -0
- package/dist/authorization/helpers.js.map +1 -1
- package/dist/authorization/index.d.ts +1 -1
- package/dist/authorization/index.d.ts.map +1 -1
- package/dist/authorization/index.js +1 -1
- package/dist/authorization/index.js.map +1 -1
- package/dist/build/generate-ai-content.d.ts.map +1 -1
- package/dist/build/generate-ai-content.js +7 -0
- package/dist/build/generate-ai-content.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +2 -1
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/generate-ai-content.js +1150 -355
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +40 -13
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +88 -60
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/template-files/canopy.ts.template +2 -1
- package/dist/cli/template-files/edit-page.tsx.template +8 -1
- package/dist/cli/template-files/middleware.ts.template +18 -0
- package/dist/cli/templates.d.ts.map +1 -1
- package/dist/cli/templates.js +19 -4
- package/dist/cli/templates.js.map +1 -1
- package/dist/config/helpers.d.ts +9 -1
- package/dist/config/helpers.d.ts.map +1 -1
- package/dist/config/helpers.js +33 -75
- package/dist/config/helpers.js.map +1 -1
- package/dist/config/schemas/config.d.ts +8 -24
- package/dist/config/schemas/config.d.ts.map +1 -1
- package/dist/config/schemas/config.js +12 -5
- package/dist/config/schemas/config.js.map +1 -1
- package/dist/config/schemas/media.d.ts +1 -10
- package/dist/config/schemas/media.d.ts.map +1 -1
- package/dist/config/schemas/media.js +8 -6
- package/dist/config/schemas/media.js.map +1 -1
- package/dist/config/types.d.ts +6 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config-test.d.ts +5 -3
- package/dist/config-test.d.ts.map +1 -1
- package/dist/config-test.js +9 -1
- package/dist/config-test.js.map +1 -1
- package/dist/content-id-index.d.ts +12 -6
- package/dist/content-id-index.d.ts.map +1 -1
- package/dist/content-id-index.js +12 -6
- package/dist/content-id-index.js.map +1 -1
- package/dist/content-index-generation.d.ts +30 -0
- package/dist/content-index-generation.d.ts.map +1 -0
- package/dist/content-index-generation.js +131 -0
- package/dist/content-index-generation.js.map +1 -0
- package/dist/content-index-registry.d.ts +38 -0
- package/dist/content-index-registry.d.ts.map +1 -0
- package/dist/content-index-registry.js +53 -0
- package/dist/content-index-registry.js.map +1 -0
- package/dist/content-listing.d.ts +3 -1
- package/dist/content-listing.d.ts.map +1 -1
- package/dist/content-listing.js +1 -0
- package/dist/content-listing.js.map +1 -1
- package/dist/content-store.d.ts +130 -6
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +343 -31
- package/dist/content-store.js.map +1 -1
- package/dist/editor/Editor.d.ts.map +1 -1
- package/dist/editor/Editor.js +27 -19
- package/dist/editor/Editor.js.map +1 -1
- package/dist/editor/FormRenderer.d.ts +7 -0
- package/dist/editor/FormRenderer.d.ts.map +1 -1
- package/dist/editor/FormRenderer.js +12 -3
- package/dist/editor/FormRenderer.js.map +1 -1
- package/dist/editor/components/EditorHeader.d.ts.map +1 -1
- package/dist/editor/components/EditorHeader.js +3 -4
- package/dist/editor/components/EditorHeader.js.map +1 -1
- package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
- package/dist/editor/components/EditorSidebar.js +2 -4
- package/dist/editor/components/EditorSidebar.js.map +1 -1
- package/dist/editor/fields/ReferenceField.d.ts.map +1 -1
- package/dist/editor/fields/ReferenceField.js +19 -2
- package/dist/editor/fields/ReferenceField.js.map +1 -1
- package/dist/editor/group-manager/hooks/useGroupState.d.ts.map +1 -1
- package/dist/editor/group-manager/hooks/useGroupState.js +5 -2
- package/dist/editor/group-manager/hooks/useGroupState.js.map +1 -1
- package/dist/editor/hooks/useDraftManager.d.ts +15 -0
- package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
- package/dist/editor/hooks/useDraftManager.js +140 -1
- package/dist/editor/hooks/useDraftManager.js.map +1 -1
- package/dist/editor/hooks/useEntryManager.d.ts +9 -2
- package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
- package/dist/editor/hooks/useEntryManager.js +9 -3
- package/dist/editor/hooks/useEntryManager.js.map +1 -1
- package/dist/editor/hooks/useSchemaManager.d.ts +34 -10
- package/dist/editor/hooks/useSchemaManager.d.ts.map +1 -1
- package/dist/editor/hooks/useSchemaManager.js +22 -25
- package/dist/editor/hooks/useSchemaManager.js.map +1 -1
- package/dist/editor/schema-editor/CollectionEditor.d.ts +6 -5
- package/dist/editor/schema-editor/CollectionEditor.d.ts.map +1 -1
- package/dist/editor/schema-editor/CollectionEditor.js +37 -12
- package/dist/editor/schema-editor/CollectionEditor.js.map +1 -1
- package/dist/entry-link-resolver.d.ts.map +1 -1
- package/dist/entry-link-resolver.js +5 -0
- package/dist/entry-link-resolver.js.map +1 -1
- package/dist/git-manager.d.ts +38 -3
- package/dist/git-manager.d.ts.map +1 -1
- package/dist/git-manager.js +141 -33
- package/dist/git-manager.js.map +1 -1
- package/dist/github-service.d.ts +69 -3
- package/dist/github-service.d.ts.map +1 -1
- package/dist/github-service.js +149 -25
- package/dist/github-service.js.map +1 -1
- package/dist/http/handler.d.ts +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/handler.js +36 -7
- package/dist/http/handler.js.map +1 -1
- package/dist/http/router.d.ts +11 -0
- package/dist/http/router.d.ts.map +1 -1
- package/dist/http/router.js +80 -16
- package/dist/http/router.js.map +1 -1
- package/dist/paths/validation.d.ts.map +1 -1
- package/dist/paths/validation.js +12 -0
- package/dist/paths/validation.js.map +1 -1
- package/dist/schema/schema-store.d.ts +9 -0
- package/dist/schema/schema-store.d.ts.map +1 -1
- package/dist/schema/schema-store.js +57 -8
- package/dist/schema/schema-store.js.map +1 -1
- package/dist/services.d.ts +1 -0
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +9 -2
- package/dist/services.js.map +1 -1
- package/dist/static/index.d.ts +32 -0
- package/dist/static/index.d.ts.map +1 -1
- package/dist/static/index.js +90 -0
- package/dist/static/index.js.map +1 -1
- package/dist/sync-core.d.ts.map +1 -1
- package/dist/sync-core.js +22 -9
- package/dist/sync-core.js.map +1 -1
- package/dist/user.d.ts +0 -18
- package/dist/user.d.ts.map +1 -1
- package/dist/user.js +7 -22
- package/dist/user.js.map +1 -1
- package/dist/utils/error.d.ts +8 -0
- package/dist/utils/error.d.ts.map +1 -1
- package/dist/utils/error.js +35 -7
- package/dist/utils/error.js.map +1 -1
- package/dist/validation/deletion-checker.d.ts.map +1 -1
- package/dist/validation/deletion-checker.js +6 -5
- package/dist/validation/deletion-checker.js.map +1 -1
- package/dist/validation/entry-validator.d.ts +67 -0
- package/dist/validation/entry-validator.d.ts.map +1 -0
- package/dist/validation/entry-validator.js +277 -0
- package/dist/validation/entry-validator.js.map +1 -0
- package/dist/validation/field-traversal.d.ts +23 -1
- package/dist/validation/field-traversal.d.ts.map +1 -1
- package/dist/validation/field-traversal.js +29 -9
- package/dist/validation/field-traversal.js.map +1 -1
- package/dist/worker/cms-worker.d.ts +70 -6
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +199 -93
- package/dist/worker/cms-worker.js.map +1 -1
- package/package.json +2 -2
- package/dist/editor/components/EditorContext.d.ts +0 -35
- package/dist/editor/components/EditorContext.d.ts.map +0 -1
- package/dist/editor/components/EditorContext.js +0 -19
- package/dist/editor/components/EditorContext.js.map +0 -1
- package/dist/editor/env.d.ts +0 -5
- package/dist/editor/env.d.ts.map +0 -1
- package/dist/editor/env.js +0 -5
- package/dist/editor/env.js.map +0 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* according to a schema, handling objects, blocks, and arrays with their
|
|
6
6
|
* own schemas.
|
|
7
7
|
*/
|
|
8
|
-
import type { FieldConfig } from '../config';
|
|
8
|
+
import type { FieldConfig, BlockFieldConfig } from '../config';
|
|
9
9
|
/**
|
|
10
10
|
* Context provided to the visitor function for each field.
|
|
11
11
|
*/
|
|
@@ -22,6 +22,28 @@ export interface TraversalContext {
|
|
|
22
22
|
* Return an empty array to skip this field, or return items to collect.
|
|
23
23
|
*/
|
|
24
24
|
export type FieldVisitor<T> = (context: TraversalContext) => T[];
|
|
25
|
+
/**
|
|
26
|
+
* A block item resolved to its template fields and the data record that
|
|
27
|
+
* holds the nested field values.
|
|
28
|
+
*/
|
|
29
|
+
export interface ResolvedBlockItem {
|
|
30
|
+
/** The template's field schema */
|
|
31
|
+
fields: FieldConfig[];
|
|
32
|
+
/** The record containing the block's nested field values */
|
|
33
|
+
data: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a block item to its template fields and nested data record.
|
|
37
|
+
*
|
|
38
|
+
* Real block data — as produced by the editor (BlockField.tsx) and persisted
|
|
39
|
+
* by ContentStore — has the shape `{ template: string, value: {...} }`:
|
|
40
|
+
* the template name lives on `template` and the nested field values live
|
|
41
|
+
* under `value`. A `_type` discriminator with inline field values is also
|
|
42
|
+
* accepted defensively (mirroring ai/json-to-markdown.ts).
|
|
43
|
+
*
|
|
44
|
+
* Returns undefined when the item has no resolvable template.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveBlockItem(blockField: BlockFieldConfig, item: Record<string, unknown>): ResolvedBlockItem | undefined;
|
|
25
47
|
/**
|
|
26
48
|
* Recursively traverse fields in data according to schema.
|
|
27
49
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-traversal.d.ts","sourceRoot":"","sources":["../../src/validation/field-traversal.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,WAAW,
|
|
1
|
+
{"version":3,"file":"field-traversal.d.ts","sourceRoot":"","sources":["../../src/validation/field-traversal.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,WAAW,EAEX,gBAAgB,EAEjB,MAAM,WAAW,CAAA;AAElB;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,8CAA8C;IAC9C,KAAK,EAAE,WAAW,CAAA;IAClB,0CAA0C;IAC1C,KAAK,EAAE,OAAO,CAAA;IACd,qFAAqF;IACrF,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,gBAAgB,KAAK,CAAC,EAAE,CAAA;AAEhE;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,kCAAkC;IAClC,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC9B;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,gBAAgB,EAC5B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,iBAAiB,GAAG,SAAS,CAkB/B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAC9B,MAAM,EAAE,SAAS,WAAW,EAAE,EAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,UAAU,SAAK,GACd,CAAC,EAAE,CA0EL;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,WAAW,EAAE,EAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,SAAS,EAAE,MAAM,GAChB,gBAAgB,EAAE,CAOpB"}
|
|
@@ -5,16 +5,36 @@
|
|
|
5
5
|
* according to a schema, handling objects, blocks, and arrays with their
|
|
6
6
|
* own schemas.
|
|
7
7
|
*/
|
|
8
|
+
function isPlainRecord(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
8
11
|
/**
|
|
9
|
-
*
|
|
12
|
+
* Resolve a block item to its template fields and nested data record.
|
|
13
|
+
*
|
|
14
|
+
* Real block data — as produced by the editor (BlockField.tsx) and persisted
|
|
15
|
+
* by ContentStore — has the shape `{ template: string, value: {...} }`:
|
|
16
|
+
* the template name lives on `template` and the nested field values live
|
|
17
|
+
* under `value`. A `_type` discriminator with inline field values is also
|
|
18
|
+
* accepted defensively (mirroring ai/json-to-markdown.ts).
|
|
19
|
+
*
|
|
20
|
+
* Returns undefined when the item has no resolvable template.
|
|
10
21
|
*/
|
|
11
|
-
function
|
|
12
|
-
const
|
|
13
|
-
|
|
22
|
+
export function resolveBlockItem(blockField, item) {
|
|
23
|
+
const templateName = typeof item.template === 'string'
|
|
24
|
+
? item.template
|
|
25
|
+
: typeof item._type === 'string'
|
|
26
|
+
? item._type
|
|
27
|
+
: undefined;
|
|
28
|
+
if (!templateName)
|
|
14
29
|
return undefined;
|
|
15
30
|
// Block fields use 'templates' property
|
|
16
|
-
const template = blockField.templates?.find((t) => t.name ===
|
|
17
|
-
|
|
31
|
+
const template = blockField.templates?.find((t) => t.name === templateName);
|
|
32
|
+
if (!template?.fields)
|
|
33
|
+
return undefined;
|
|
34
|
+
// Nested field values live under `value` in the canonical shape; fall back
|
|
35
|
+
// to the item itself for the inline `_type` shape.
|
|
36
|
+
const data = isPlainRecord(item.value) ? item.value : item;
|
|
37
|
+
return { fields: template.fields, data };
|
|
18
38
|
}
|
|
19
39
|
/**
|
|
20
40
|
* Recursively traverse fields in data according to schema.
|
|
@@ -81,9 +101,9 @@ export function traverseFields(fields, data, visitor, pathPrefix = '') {
|
|
|
81
101
|
if (Array.isArray(value)) {
|
|
82
102
|
value.forEach((item, index) => {
|
|
83
103
|
if (typeof item === 'object' && item !== null) {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
results.push(...traverseFields(
|
|
104
|
+
const resolved = resolveBlockItem(blockField, item);
|
|
105
|
+
if (resolved) {
|
|
106
|
+
results.push(...traverseFields(resolved.fields, resolved.data, visitor, `${fieldPath}[${index}]`));
|
|
87
107
|
}
|
|
88
108
|
}
|
|
89
109
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-traversal.js","sourceRoot":"","sources":["../../src/validation/field-traversal.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"field-traversal.js","sourceRoot":"","sources":["../../src/validation/field-traversal.ts"],"names":[],"mappings":"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"}
|
|
@@ -35,7 +35,45 @@ export interface CmsWorkerConfig {
|
|
|
35
35
|
maxRetries?: number;
|
|
36
36
|
/** Content root directory name relative to repo root (default: 'content') */
|
|
37
37
|
contentRoot?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Worker lock staleness TTL in ms (default: 60000, minimum 2000).
|
|
40
|
+
* The holder refreshes the lock heartbeat at half this interval; a lock
|
|
41
|
+
* whose heartbeat is older than this is considered abandoned and taken
|
|
42
|
+
* over by the next worker to start.
|
|
43
|
+
*/
|
|
44
|
+
lockStaleMs?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* An error inherent to the task itself (malformed payload, unknown action):
|
|
48
|
+
* retrying can never succeed, so the task should fail fast instead of
|
|
49
|
+
* burning its retry budget.
|
|
50
|
+
*/
|
|
51
|
+
export declare class PermanentTaskError extends Error {
|
|
38
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Classify a task failure as permanent (fail fast) or transient (retry).
|
|
55
|
+
*
|
|
56
|
+
* Transient — worth retrying with backoff:
|
|
57
|
+
* - network errors / anything without an HTTP status (git failures included:
|
|
58
|
+
* most push/fetch failures are connectivity or contention and the retry
|
|
59
|
+
* budget bounds the pathological cases)
|
|
60
|
+
* - HTTP 408 (request timeout) and 429 (rate limited)
|
|
61
|
+
* - HTTP 403 that carries a rate-limit signal (see `isRateLimitSignal403`):
|
|
62
|
+
* GitHub returns 403, not 429, for both primary and secondary/abuse rate
|
|
63
|
+
* limits. The throttling plugin (see github-service.ts createCanopyOctokit)
|
|
64
|
+
* proactively retries short waits, but this carve-out remains the safety
|
|
65
|
+
* net for waits the plugin gives up on (`shouldRetryRateLimit`/
|
|
66
|
+
* `shouldRetrySecondaryRateLimit`) and for errors it never sees — without
|
|
67
|
+
* it a rate-limited push-and-create-or-update-pr task would fail
|
|
68
|
+
* permanently and wedge the branch (`sync-failed`, no retry).
|
|
69
|
+
* - HTTP 5xx (server-side, usually recovers)
|
|
70
|
+
*
|
|
71
|
+
* Permanent — retrying the identical request cannot succeed:
|
|
72
|
+
* - PermanentTaskError (malformed payload, unknown action)
|
|
73
|
+
* - other HTTP 4xx (e.g. 401/404/422): the request itself is bad
|
|
74
|
+
* - plain HTTP 403 with no rate-limit signal: a real permission denial
|
|
75
|
+
*/
|
|
76
|
+
export declare function isPermanentTaskFailure(err: unknown): boolean;
|
|
39
77
|
/**
|
|
40
78
|
* CMS Worker daemon.
|
|
41
79
|
* Handles operations that Lambda (with no internet) cannot perform:
|
|
@@ -61,19 +99,40 @@ export declare class CmsWorker {
|
|
|
61
99
|
private taskTimeoutMs;
|
|
62
100
|
private maxRetries;
|
|
63
101
|
private lockFilePath;
|
|
102
|
+
private lockStaleMs;
|
|
103
|
+
private releaseLockFn;
|
|
64
104
|
private contentRoot;
|
|
65
105
|
private log;
|
|
66
106
|
constructor(config: CmsWorkerConfig);
|
|
67
107
|
start(): Promise<void>;
|
|
68
108
|
stop(): Promise<void>;
|
|
69
109
|
/**
|
|
70
|
-
* Acquire
|
|
71
|
-
*
|
|
72
|
-
*
|
|
110
|
+
* Acquire the cross-host worker lock (DEP-C2).
|
|
111
|
+
*
|
|
112
|
+
* The task queue is single-consumer (see task-queue/task-queue.ts): two
|
|
113
|
+
* concurrent workers would double-process tasks (duplicate pushes, duplicate
|
|
114
|
+
* PRs). The workspace lives on a shared filesystem (EFS), so mutual
|
|
115
|
+
* exclusion must be sound ACROSS HOSTS — a PID liveness probe
|
|
116
|
+
* (`process.kill(pid, 0)`) only means something on the holder's own machine
|
|
117
|
+
* and must never participate in staleness decisions.
|
|
118
|
+
*
|
|
119
|
+
* proper-lockfile provides a heartbeat lease with no PID involved: the lock
|
|
120
|
+
* is a directory created atomically (mkdir — atomic on NFS/EFS), the holder
|
|
121
|
+
* refreshes its mtime every lockStaleMs/2, and the lock is considered
|
|
122
|
+
* abandoned — and taken over — only when that heartbeat is older than
|
|
123
|
+
* lockStaleMs. Liveness is judged purely by heartbeat freshness.
|
|
124
|
+
*
|
|
125
|
+
* No acquire retries: a second worker exits immediately, matching daemon
|
|
126
|
+
* semantics (the supervisor restarts it later). After a crash, the dead
|
|
127
|
+
* holder's heartbeat expires within lockStaleMs and the next start succeeds.
|
|
128
|
+
*
|
|
129
|
+
* Staleness is judged by comparing the lock's mtime against the local
|
|
130
|
+
* clock, so correct cross-host takeover assumes reasonable clock agreement
|
|
131
|
+
* between hosts (e.g. NTP); with the default TTL, ordinary clock skew is
|
|
132
|
+
* negligible, but a host with a badly wrong clock could misjudge liveness.
|
|
73
133
|
*/
|
|
74
134
|
private acquireLock;
|
|
75
135
|
private releaseLock;
|
|
76
|
-
private isPidAlive;
|
|
77
136
|
/**
|
|
78
137
|
* Schedule a function to run repeatedly with setTimeout chaining.
|
|
79
138
|
* The next invocation starts `interval` ms after the previous one completes,
|
|
@@ -93,8 +152,13 @@ export declare class CmsWorker {
|
|
|
93
152
|
*/
|
|
94
153
|
processTaskQueue(): Promise<void>;
|
|
95
154
|
/**
|
|
96
|
-
* Execute a task
|
|
97
|
-
*
|
|
155
|
+
* Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
|
|
156
|
+
* - An AbortSignal cancels Octokit HTTP calls promptly.
|
|
157
|
+
* - A Promise.race rejects when the timeout fires, so work that cannot
|
|
158
|
+
* observe the signal (git subprocesses via simple-git) still fails the
|
|
159
|
+
* attempt and the worker moves on instead of stalling forever.
|
|
160
|
+
* pushBranchToGitHub additionally kills stalled git processes via
|
|
161
|
+
* simple-git's block timeout, so a hung push doesn't leak a process.
|
|
98
162
|
*/
|
|
99
163
|
private executeTaskWithTimeout;
|
|
100
164
|
private executeTask;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cms-worker.d.ts","sourceRoot":"","sources":["../../src/worker/cms-worker.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cms-worker.d.ts","sourceRoot":"","sources":["../../src/worker/cms-worker.ts"],"names":[],"mappings":"AAsBA;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;AAEpD,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,aAAa,EAAE,MAAM,CAAA;IACrB,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAA;IACnB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAA;IAClB,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAA;IACnB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,kBAAkB,CAAA;IACrC,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,uDAAuD;IACvD,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,kEAAkE;IAClE,wBAAwB,CAAC,EAAE,MAAM,CAAA;IACjC,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAMD;;;;GAIG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;CAAG;AAEhD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAO5D;AA2DD;;;;;;;;;;GAUG;AACH,qBAAa,SAAS;IAkBR,OAAO,CAAC,MAAM;IAjB1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,mBAAmB,CAAQ;IACnC,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,gBAAgB,CAA2B;IACnD,OAAO,CAAC,gBAAgB,CAAQ;IAChC,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,aAAa,CAAqC;IAC1D,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,GAAG,CAAqB;gBAEZ,MAAM,EAAE,eAAe;IAcrC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAmB3B;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;YACW,WAAW;YAyBX,WAAW;IAWzB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAkBpB;;;OAGG;YACW,eAAe;IAe7B;;;;;OAKG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAwCvC;;;;;;;;OAQG;YACW,sBAAsB;YAqBtB,WAAW;IAkHzB;;;OAGG;YACW,oBAAoB;IA4BlC;;;OAGG;YACW,6BAA6B;IAsB3C,OAAO,CAAC,cAAc;YAIR,kBAAkB;IAahC;;;OAGG;YACW,oBAAoB;IAqB5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YA4BhB,oBAAoB;IA8L5B,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;CAYxC"}
|
|
@@ -1,25 +1,110 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { simpleGit } from 'simple-git';
|
|
4
|
-
import
|
|
4
|
+
import lockfile from 'proper-lockfile';
|
|
5
5
|
import { dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
|
|
6
|
+
import { createOrUpdatePullRequest, createCanopyOctokit } from '../github-service.js';
|
|
6
7
|
import { getBranchMetadataFileManager, BranchMetadataFileManager } from '../branch-metadata.js';
|
|
7
8
|
import { extractIdFromFilename } from '../content-id-index.js';
|
|
9
|
+
import { invalidateContentIndexesDurable } from '../content-index-generation.js';
|
|
8
10
|
import { ROOT_COLLECTION_ID } from '../paths/types.js';
|
|
9
|
-
import {
|
|
11
|
+
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
10
12
|
const DEFAULT_TASK_TIMEOUT = 60_000;
|
|
11
13
|
const DEFAULT_MAX_RETRIES = 3;
|
|
14
|
+
const DEFAULT_LOCK_STALE_MS = 60_000;
|
|
15
|
+
/**
|
|
16
|
+
* An error inherent to the task itself (malformed payload, unknown action):
|
|
17
|
+
* retrying can never succeed, so the task should fail fast instead of
|
|
18
|
+
* burning its retry budget.
|
|
19
|
+
*/
|
|
20
|
+
export class PermanentTaskError extends Error {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Classify a task failure as permanent (fail fast) or transient (retry).
|
|
24
|
+
*
|
|
25
|
+
* Transient — worth retrying with backoff:
|
|
26
|
+
* - network errors / anything without an HTTP status (git failures included:
|
|
27
|
+
* most push/fetch failures are connectivity or contention and the retry
|
|
28
|
+
* budget bounds the pathological cases)
|
|
29
|
+
* - HTTP 408 (request timeout) and 429 (rate limited)
|
|
30
|
+
* - HTTP 403 that carries a rate-limit signal (see `isRateLimitSignal403`):
|
|
31
|
+
* GitHub returns 403, not 429, for both primary and secondary/abuse rate
|
|
32
|
+
* limits. The throttling plugin (see github-service.ts createCanopyOctokit)
|
|
33
|
+
* proactively retries short waits, but this carve-out remains the safety
|
|
34
|
+
* net for waits the plugin gives up on (`shouldRetryRateLimit`/
|
|
35
|
+
* `shouldRetrySecondaryRateLimit`) and for errors it never sees — without
|
|
36
|
+
* it a rate-limited push-and-create-or-update-pr task would fail
|
|
37
|
+
* permanently and wedge the branch (`sync-failed`, no retry).
|
|
38
|
+
* - HTTP 5xx (server-side, usually recovers)
|
|
39
|
+
*
|
|
40
|
+
* Permanent — retrying the identical request cannot succeed:
|
|
41
|
+
* - PermanentTaskError (malformed payload, unknown action)
|
|
42
|
+
* - other HTTP 4xx (e.g. 401/404/422): the request itself is bad
|
|
43
|
+
* - plain HTTP 403 with no rate-limit signal: a real permission denial
|
|
44
|
+
*/
|
|
45
|
+
export function isPermanentTaskFailure(err) {
|
|
46
|
+
if (err instanceof PermanentTaskError)
|
|
47
|
+
return true;
|
|
48
|
+
const status = getHttpStatus(err);
|
|
49
|
+
if (status === null)
|
|
50
|
+
return false;
|
|
51
|
+
if (status === 408 || status === 429)
|
|
52
|
+
return false;
|
|
53
|
+
if (status === 403 && isRateLimitSignal403(err))
|
|
54
|
+
return false;
|
|
55
|
+
return status >= 400 && status < 500;
|
|
56
|
+
}
|
|
57
|
+
/** Extract an HTTP status from an error, if present (Octokit RequestError shape). */
|
|
58
|
+
function getHttpStatus(err) {
|
|
59
|
+
if (err instanceof Error && 'status' in err) {
|
|
60
|
+
const status = err.status;
|
|
61
|
+
if (typeof status === 'number')
|
|
62
|
+
return status;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** Narrow an unknown value to a response-headers record, if present (Octokit lowercases header names). */
|
|
67
|
+
function getResponseHeaders(err) {
|
|
68
|
+
if (typeof err !== 'object' || err === null || !('response' in err))
|
|
69
|
+
return null;
|
|
70
|
+
const response = err.response;
|
|
71
|
+
if (typeof response !== 'object' || response === null || !('headers' in response))
|
|
72
|
+
return null;
|
|
73
|
+
const headers = response.headers;
|
|
74
|
+
if (typeof headers !== 'object' || headers === null)
|
|
75
|
+
return null;
|
|
76
|
+
return headers;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Detect whether a 403 is a GitHub rate-limit response rather than a plain
|
|
80
|
+
* permission denial. GitHub signals rate limiting on 403s three ways: the
|
|
81
|
+
* primary limit zeroes out `x-ratelimit-remaining`, secondary/abuse limits
|
|
82
|
+
* often include a `retry-after` header, and both cases produce a message
|
|
83
|
+
* containing "rate limit" (e.g. "You have exceeded a secondary rate limit").
|
|
84
|
+
*/
|
|
85
|
+
function isRateLimitSignal403(err) {
|
|
86
|
+
const headers = getResponseHeaders(err);
|
|
87
|
+
if (headers) {
|
|
88
|
+
if (headers['x-ratelimit-remaining'] === '0')
|
|
89
|
+
return true;
|
|
90
|
+
if (typeof headers['retry-after'] === 'string' && headers['retry-after'].length > 0)
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (err instanceof Error && /rate limit/i.test(err.message))
|
|
94
|
+
return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
12
97
|
// Payload validation helpers — fail fast with clear errors instead of silent `as` casts
|
|
13
98
|
function requireString(payload, key) {
|
|
14
99
|
const val = payload[key];
|
|
15
100
|
if (typeof val !== 'string')
|
|
16
|
-
throw new
|
|
101
|
+
throw new PermanentTaskError(`Task payload missing required string field: ${key}`);
|
|
17
102
|
return val;
|
|
18
103
|
}
|
|
19
104
|
function requireNumber(payload, key) {
|
|
20
105
|
const val = payload[key];
|
|
21
106
|
if (typeof val !== 'number')
|
|
22
|
-
throw new
|
|
107
|
+
throw new PermanentTaskError(`Task payload missing required number field: ${key}`);
|
|
23
108
|
return val;
|
|
24
109
|
}
|
|
25
110
|
function optionalString(payload, key, fallback) {
|
|
@@ -43,8 +128,9 @@ export class CmsWorker {
|
|
|
43
128
|
this.activeTimeouts = new Set();
|
|
44
129
|
this.running = false;
|
|
45
130
|
this.activeOperations = new Set();
|
|
131
|
+
this.releaseLockFn = null;
|
|
46
132
|
this.log = cmsTaskQueueLogger;
|
|
47
|
-
this.octokit =
|
|
133
|
+
this.octokit = createCanopyOctokit({ auth: config.githubToken });
|
|
48
134
|
this.taskDir = path.join(config.workspacePath, '.tasks');
|
|
49
135
|
this.remoteGitPath = path.join(config.workspacePath, 'remote.git');
|
|
50
136
|
this.contentBranchesPath = path.join(config.workspacePath, 'content-branches');
|
|
@@ -53,6 +139,7 @@ export class CmsWorker {
|
|
|
53
139
|
this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
|
|
54
140
|
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
55
141
|
this.lockFilePath = path.join(config.workspacePath, '.tasks', '.worker-lock');
|
|
142
|
+
this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
|
|
56
143
|
this.contentRoot = config.contentRoot ?? 'content';
|
|
57
144
|
}
|
|
58
145
|
async start() {
|
|
@@ -107,72 +194,63 @@ export class CmsWorker {
|
|
|
107
194
|
console.log('CMS Worker stopped');
|
|
108
195
|
}
|
|
109
196
|
/**
|
|
110
|
-
* Acquire
|
|
111
|
-
*
|
|
112
|
-
*
|
|
197
|
+
* Acquire the cross-host worker lock (DEP-C2).
|
|
198
|
+
*
|
|
199
|
+
* The task queue is single-consumer (see task-queue/task-queue.ts): two
|
|
200
|
+
* concurrent workers would double-process tasks (duplicate pushes, duplicate
|
|
201
|
+
* PRs). The workspace lives on a shared filesystem (EFS), so mutual
|
|
202
|
+
* exclusion must be sound ACROSS HOSTS — a PID liveness probe
|
|
203
|
+
* (`process.kill(pid, 0)`) only means something on the holder's own machine
|
|
204
|
+
* and must never participate in staleness decisions.
|
|
205
|
+
*
|
|
206
|
+
* proper-lockfile provides a heartbeat lease with no PID involved: the lock
|
|
207
|
+
* is a directory created atomically (mkdir — atomic on NFS/EFS), the holder
|
|
208
|
+
* refreshes its mtime every lockStaleMs/2, and the lock is considered
|
|
209
|
+
* abandoned — and taken over — only when that heartbeat is older than
|
|
210
|
+
* lockStaleMs. Liveness is judged purely by heartbeat freshness.
|
|
211
|
+
*
|
|
212
|
+
* No acquire retries: a second worker exits immediately, matching daemon
|
|
213
|
+
* semantics (the supervisor restarts it later). After a crash, the dead
|
|
214
|
+
* holder's heartbeat expires within lockStaleMs and the next start succeeds.
|
|
215
|
+
*
|
|
216
|
+
* Staleness is judged by comparing the lock's mtime against the local
|
|
217
|
+
* clock, so correct cross-host takeover assumes reasonable clock agreement
|
|
218
|
+
* between hosts (e.g. NTP); with the default TTL, ordinary clock skew is
|
|
219
|
+
* negligible, but a host with a badly wrong clock could misjudge liveness.
|
|
113
220
|
*/
|
|
114
221
|
async acquireLock() {
|
|
115
|
-
await fs.mkdir(
|
|
116
|
-
const lockContent = JSON.stringify({
|
|
117
|
-
pid: process.pid,
|
|
118
|
-
timestamp: new Date().toISOString(),
|
|
119
|
-
});
|
|
120
|
-
// Try atomic create first
|
|
222
|
+
await fs.mkdir(this.taskDir, { recursive: true });
|
|
121
223
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
224
|
+
this.releaseLockFn = await lockfile.lock(this.taskDir, {
|
|
225
|
+
lockfilePath: this.lockFilePath,
|
|
226
|
+
stale: this.lockStaleMs,
|
|
227
|
+
onCompromised: (err) => {
|
|
228
|
+
// Our heartbeat could not be maintained (lock deleted or taken
|
|
229
|
+
// over). Another worker may now be consuming the queue — stop
|
|
230
|
+
// processing to preserve the single-consumer invariant.
|
|
231
|
+
console.error('Worker lock compromised, shutting down:', getErrorMessage(err));
|
|
232
|
+
this.releaseLockFn = null; // the lock is already lost; nothing to release
|
|
233
|
+
void this.stop();
|
|
234
|
+
},
|
|
235
|
+
});
|
|
126
236
|
}
|
|
127
237
|
catch (err) {
|
|
128
|
-
if (
|
|
129
|
-
throw
|
|
130
|
-
}
|
|
131
|
-
// Lock file exists — check staleness
|
|
132
|
-
try {
|
|
133
|
-
const content = await fs.readFile(this.lockFilePath, 'utf-8');
|
|
134
|
-
const { pid, timestamp } = JSON.parse(content);
|
|
135
|
-
const lockAgeMs = Date.now() - new Date(timestamp).getTime();
|
|
136
|
-
const pidAlive = this.isPidAlive(pid);
|
|
137
|
-
if (pidAlive && lockAgeMs < 10 * 60_000) {
|
|
138
|
-
throw new Error(`Another worker is running (PID ${pid}, locked at ${timestamp}). Exiting.`);
|
|
139
|
-
}
|
|
140
|
-
console.log(`Overwriting stale lock (PID ${pid}, age ${Math.round(lockAgeMs / 1000)}s)`);
|
|
141
|
-
}
|
|
142
|
-
catch (err) {
|
|
143
|
-
if (err instanceof Error && err.message.startsWith('Another worker'))
|
|
144
|
-
throw err;
|
|
145
|
-
// Lock file is corrupt or unreadable — overwrite it
|
|
146
|
-
}
|
|
147
|
-
// Stale or corrupt lock — unlink and retry with atomic create
|
|
148
|
-
await fs.unlink(this.lockFilePath).catch(() => { });
|
|
149
|
-
try {
|
|
150
|
-
const handle = await fs.open(this.lockFilePath, 'wx');
|
|
151
|
-
await handle.writeFile(lockContent, 'utf-8');
|
|
152
|
-
await handle.close();
|
|
153
|
-
}
|
|
154
|
-
catch (err) {
|
|
155
|
-
if (isFileExistsError(err)) {
|
|
156
|
-
throw new Error('Another worker acquired the lock during stale lock recovery. Exiting.');
|
|
238
|
+
if (isNodeError(err) && err.code === 'ELOCKED') {
|
|
239
|
+
throw new Error(`Another worker is running (lock ${this.lockFilePath} has a heartbeat fresher than ${this.lockStaleMs}ms). Exiting.`);
|
|
157
240
|
}
|
|
158
241
|
throw err;
|
|
159
242
|
}
|
|
160
243
|
}
|
|
161
244
|
async releaseLock() {
|
|
245
|
+
const release = this.releaseLockFn;
|
|
246
|
+
this.releaseLockFn = null;
|
|
247
|
+
if (!release)
|
|
248
|
+
return;
|
|
162
249
|
try {
|
|
163
|
-
await
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
// Lock file already gone
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
isPidAlive(pid) {
|
|
170
|
-
try {
|
|
171
|
-
process.kill(pid, 0);
|
|
172
|
-
return true;
|
|
250
|
+
await release();
|
|
173
251
|
}
|
|
174
252
|
catch {
|
|
175
|
-
|
|
253
|
+
// Lock already released or compromised
|
|
176
254
|
}
|
|
177
255
|
}
|
|
178
256
|
/**
|
|
@@ -236,32 +314,50 @@ export class CmsWorker {
|
|
|
236
314
|
await this.updateBranchMetadata(task, result);
|
|
237
315
|
}
|
|
238
316
|
catch (err) {
|
|
239
|
-
const message = err
|
|
317
|
+
const message = getErrorMessage(err);
|
|
240
318
|
console.error(`Task ${task.id} (${task.action}) failed:`, message);
|
|
319
|
+
// DEP-L1: only transient failures (network, 429/5xx, timeouts) are
|
|
320
|
+
// worth retrying; permanent ones (malformed payload, other 4xx) would
|
|
321
|
+
// just burn the retry budget on an identical doomed request.
|
|
322
|
+
const permanent = isPermanentTaskFailure(err);
|
|
241
323
|
const retryCount = task.retryCount ?? 0;
|
|
242
324
|
const maxRetries = task.maxRetries ?? this.maxRetries;
|
|
243
|
-
if (retryCount < maxRetries) {
|
|
325
|
+
if (!permanent && retryCount < maxRetries) {
|
|
244
326
|
await retryTask(this.taskDir, task.id, message, this.log);
|
|
245
327
|
console.log(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
|
|
246
328
|
}
|
|
247
329
|
else {
|
|
248
330
|
await failTask(this.taskDir, task.id, message, this.log);
|
|
249
331
|
await this.updateBranchMetadataOnFailure(task, message);
|
|
250
|
-
console.error(
|
|
332
|
+
console.error(permanent
|
|
333
|
+
? ' Permanently failed (non-retryable error)'
|
|
334
|
+
: ` Permanently failed after ${maxRetries} retries`);
|
|
251
335
|
}
|
|
252
336
|
}
|
|
253
337
|
processed++;
|
|
254
338
|
}
|
|
255
339
|
}
|
|
256
340
|
/**
|
|
257
|
-
* Execute a task
|
|
258
|
-
*
|
|
341
|
+
* Execute a task bounded by taskTimeoutMs (DEP-H1). Two layers:
|
|
342
|
+
* - An AbortSignal cancels Octokit HTTP calls promptly.
|
|
343
|
+
* - A Promise.race rejects when the timeout fires, so work that cannot
|
|
344
|
+
* observe the signal (git subprocesses via simple-git) still fails the
|
|
345
|
+
* attempt and the worker moves on instead of stalling forever.
|
|
346
|
+
* pushBranchToGitHub additionally kills stalled git processes via
|
|
347
|
+
* simple-git's block timeout, so a hung push doesn't leak a process.
|
|
259
348
|
*/
|
|
260
349
|
async executeTaskWithTimeout(task) {
|
|
261
350
|
const controller = new AbortController();
|
|
262
351
|
const timer = setTimeout(() => controller.abort(), this.taskTimeoutMs);
|
|
263
352
|
try {
|
|
264
|
-
|
|
353
|
+
const work = this.executeTask(task, controller.signal);
|
|
354
|
+
// If the timeout wins the race, the losing promise must not surface an
|
|
355
|
+
// unhandled rejection when it eventually settles.
|
|
356
|
+
work.catch(() => { });
|
|
357
|
+
const timedOut = new Promise((_, reject) => {
|
|
358
|
+
controller.signal.addEventListener('abort', () => reject(new Error(`Task timed out after ${this.taskTimeoutMs}ms`)), { once: true });
|
|
359
|
+
});
|
|
360
|
+
return await Promise.race([work, timedOut]);
|
|
265
361
|
}
|
|
266
362
|
finally {
|
|
267
363
|
clearTimeout(timer);
|
|
@@ -306,40 +402,33 @@ export class CmsWorker {
|
|
|
306
402
|
return { prNumber };
|
|
307
403
|
}
|
|
308
404
|
case 'push-and-create-or-update-pr': {
|
|
405
|
+
// GIT-H1: idempotent create-or-update. Used for both content-branch
|
|
406
|
+
// submits and settings-branch syncs so a retry after a crash (task
|
|
407
|
+
// completed on GitHub but branch metadata never recorded the PR
|
|
408
|
+
// number) recovers the existing PR instead of hitting the 422 that
|
|
409
|
+
// a blind `pulls.create` would throw on a duplicate head+base.
|
|
410
|
+
// Delegates to the shared helper (also used by GitHubService's
|
|
411
|
+
// direct-API path) so the list->tiebreak->update/create logic and
|
|
412
|
+
// the draft->ready conversion live in one place.
|
|
309
413
|
const branch = requireString(payload, 'branch');
|
|
310
414
|
await this.pushBranchToGitHub(branch);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
owner: this.config.githubOwner,
|
|
314
|
-
repo: this.config.githubRepo,
|
|
315
|
-
head: `${this.config.githubOwner}:${branch}`,
|
|
316
|
-
base: optionalString(payload, 'baseBranch', this.baseBranch),
|
|
317
|
-
state: 'open',
|
|
318
|
-
request: { signal },
|
|
319
|
-
});
|
|
320
|
-
if (existingPRs.data.length > 0) {
|
|
321
|
-
const existing = existingPRs.data[0];
|
|
322
|
-
await this.octokit.pulls.update({
|
|
323
|
-
owner: this.config.githubOwner,
|
|
324
|
-
repo: this.config.githubRepo,
|
|
325
|
-
pull_number: existing.number,
|
|
326
|
-
body: optionalString(payload, 'body', ''),
|
|
327
|
-
request: { signal },
|
|
328
|
-
});
|
|
329
|
-
console.log(`Updated existing PR #${existing.number} for ${branch}`);
|
|
330
|
-
return { prUrl: existing.html_url, prNumber: existing.number };
|
|
331
|
-
}
|
|
332
|
-
const newPr = await this.octokit.pulls.create({
|
|
415
|
+
const result = await createOrUpdatePullRequest({
|
|
416
|
+
octokit: this.octokit,
|
|
333
417
|
owner: this.config.githubOwner,
|
|
334
418
|
repo: this.config.githubRepo,
|
|
335
419
|
head: branch,
|
|
336
420
|
base: optionalString(payload, 'baseBranch', this.baseBranch),
|
|
337
|
-
title: optionalString(payload, 'title', `
|
|
421
|
+
title: optionalString(payload, 'title', `Submit ${branch}`),
|
|
338
422
|
body: optionalString(payload, 'body', ''),
|
|
339
|
-
|
|
423
|
+
// Content submits (api/github-sync.ts) set this; settings-branch
|
|
424
|
+
// syncs (services.ts) deliberately don't.
|
|
425
|
+
markReadyIfDraft: payload.markReadyIfDraft === true,
|
|
426
|
+
signal,
|
|
340
427
|
});
|
|
341
|
-
console.log(
|
|
342
|
-
|
|
428
|
+
console.log(result.created
|
|
429
|
+
? `Created PR #${result.number} for ${branch}`
|
|
430
|
+
: `Updated existing PR #${result.number} for ${branch}`);
|
|
431
|
+
return { prUrl: result.url, prNumber: result.number };
|
|
343
432
|
}
|
|
344
433
|
case 'convert-to-draft': {
|
|
345
434
|
const draftPrNumber = requireNumber(payload, 'pullRequestNumber');
|
|
@@ -377,7 +466,7 @@ export class CmsWorker {
|
|
|
377
466
|
return { deleted: true };
|
|
378
467
|
}
|
|
379
468
|
default:
|
|
380
|
-
throw new
|
|
469
|
+
throw new PermanentTaskError(`Unknown task action: ${action}`);
|
|
381
470
|
}
|
|
382
471
|
}
|
|
383
472
|
/**
|
|
@@ -438,7 +527,13 @@ export class CmsWorker {
|
|
|
438
527
|
return `https://x-access-token:${this.config.githubToken}@github.com/${this.config.githubOwner}/${this.config.githubRepo}.git`;
|
|
439
528
|
}
|
|
440
529
|
async pushBranchToGitHub(branch) {
|
|
441
|
-
const git = simpleGit({
|
|
530
|
+
const git = simpleGit({
|
|
531
|
+
baseDir: this.remoteGitPath,
|
|
532
|
+
// DEP-H1: kill the git process if it produces no output for
|
|
533
|
+
// taskTimeoutMs (network stall, credential prompt) instead of letting
|
|
534
|
+
// it hang past the task timeout.
|
|
535
|
+
timeout: { block: this.taskTimeoutMs },
|
|
536
|
+
});
|
|
442
537
|
// Pass URL directly to avoid persisting the token in remote.git/config
|
|
443
538
|
await git.push(this.buildGitHubUrl(), branch);
|
|
444
539
|
console.log(`Pushed ${branch} to GitHub`);
|
|
@@ -470,7 +565,13 @@ export class CmsWorker {
|
|
|
470
565
|
if (!this.running)
|
|
471
566
|
return;
|
|
472
567
|
console.log('Syncing git...');
|
|
473
|
-
const git = simpleGit({
|
|
568
|
+
const git = simpleGit({
|
|
569
|
+
baseDir: this.remoteGitPath,
|
|
570
|
+
// DEP-H1: a hung fetch/push would stall the sync loop forever
|
|
571
|
+
// (scheduleLoop only reschedules after completion). The block timeout
|
|
572
|
+
// is inactivity-based, so a slow-but-flowing transfer is unaffected.
|
|
573
|
+
timeout: { block: this.taskTimeoutMs },
|
|
574
|
+
});
|
|
474
575
|
// Fetch all branches from GitHub using direct URL (no named remote)
|
|
475
576
|
// We use raw git commands since simple-git's fetch() with a URL
|
|
476
577
|
// doesn't support --prune directly
|
|
@@ -608,6 +709,11 @@ export class CmsWorker {
|
|
|
608
709
|
await branchGit.rebase(['--abort']).catch(() => { });
|
|
609
710
|
continue;
|
|
610
711
|
}
|
|
712
|
+
// The rebase rewrote the branch clone's working tree — mark ContentStore
|
|
713
|
+
// ID indexes rooted here stale so lookups rebuild from disk, in this
|
|
714
|
+
// process and (via the on-disk generation marker) in the Lambda
|
|
715
|
+
// containers sharing this filesystem.
|
|
716
|
+
await invalidateContentIndexesDurable(branchPath);
|
|
611
717
|
// Convert file paths to ContentIds — immutable, survives slug renames.
|
|
612
718
|
// Entry files have IDs in their filename (e.g., "post.slug.a1b2c3d4e5f6.mdx").
|
|
613
719
|
// .collection.json files have no ID themselves (extractIdFromFilename returns null
|