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.
Files changed (225) hide show
  1. package/dist/api/assets.d.ts +14 -2
  2. package/dist/api/assets.d.ts.map +1 -1
  3. package/dist/api/assets.js +14 -3
  4. package/dist/api/assets.js.map +1 -1
  5. package/dist/api/branch-status.d.ts.map +1 -1
  6. package/dist/api/branch-status.js +8 -2
  7. package/dist/api/branch-status.js.map +1 -1
  8. package/dist/api/client.d.ts +2 -2
  9. package/dist/api/client.d.ts.map +1 -1
  10. package/dist/api/client.js +29 -14
  11. package/dist/api/client.js.map +1 -1
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/content.d.ts.map +1 -1
  14. package/dist/api/content.js +160 -26
  15. package/dist/api/content.js.map +1 -1
  16. package/dist/api/entries.d.ts.map +1 -1
  17. package/dist/api/entries.js +8 -4
  18. package/dist/api/entries.js.map +1 -1
  19. package/dist/api/github-sync.d.ts.map +1 -1
  20. package/dist/api/github-sync.js +45 -7
  21. package/dist/api/github-sync.js.map +1 -1
  22. package/dist/api/groups.d.ts.map +1 -1
  23. package/dist/api/groups.js +15 -5
  24. package/dist/api/groups.js.map +1 -1
  25. package/dist/api/permissions.d.ts +6 -6
  26. package/dist/api/permissions.d.ts.map +1 -1
  27. package/dist/api/permissions.js +24 -9
  28. package/dist/api/permissions.js.map +1 -1
  29. package/dist/api/request-body-hash.d.ts +33 -0
  30. package/dist/api/request-body-hash.d.ts.map +1 -0
  31. package/dist/api/request-body-hash.js +43 -0
  32. package/dist/api/request-body-hash.js.map +1 -0
  33. package/dist/api/resolve-references.d.ts.map +1 -1
  34. package/dist/api/resolve-references.js +8 -1
  35. package/dist/api/resolve-references.js.map +1 -1
  36. package/dist/api/schema.js +9 -9
  37. package/dist/api/schema.js.map +1 -1
  38. package/dist/api/settings-helpers.d.ts +14 -1
  39. package/dist/api/settings-helpers.d.ts.map +1 -1
  40. package/dist/api/settings-helpers.js +11 -2
  41. package/dist/api/settings-helpers.js.map +1 -1
  42. package/dist/api/types.d.ts +10 -0
  43. package/dist/api/types.d.ts.map +1 -1
  44. package/dist/auth/caching-auth-plugin.d.ts +8 -1
  45. package/dist/auth/caching-auth-plugin.d.ts.map +1 -1
  46. package/dist/auth/caching-auth-plugin.js +6 -1
  47. package/dist/auth/caching-auth-plugin.js.map +1 -1
  48. package/dist/auth/index.d.ts +1 -0
  49. package/dist/auth/index.d.ts.map +1 -1
  50. package/dist/auth/index.js +1 -0
  51. package/dist/auth/index.js.map +1 -1
  52. package/dist/auth/plugin.d.ts +35 -0
  53. package/dist/auth/plugin.d.ts.map +1 -1
  54. package/dist/auth/plugin.js +27 -1
  55. package/dist/auth/plugin.js.map +1 -1
  56. package/dist/authorization/helpers.d.ts +16 -0
  57. package/dist/authorization/helpers.d.ts.map +1 -1
  58. package/dist/authorization/helpers.js +18 -0
  59. package/dist/authorization/helpers.js.map +1 -1
  60. package/dist/authorization/index.d.ts +1 -1
  61. package/dist/authorization/index.d.ts.map +1 -1
  62. package/dist/authorization/index.js +1 -1
  63. package/dist/authorization/index.js.map +1 -1
  64. package/dist/build/generate-ai-content.d.ts.map +1 -1
  65. package/dist/build/generate-ai-content.js +7 -0
  66. package/dist/build/generate-ai-content.js.map +1 -1
  67. package/dist/cli/cli.d.ts.map +1 -1
  68. package/dist/cli/cli.js +2 -1
  69. package/dist/cli/cli.js.map +1 -1
  70. package/dist/cli/generate-ai-content.js +1150 -355
  71. package/dist/cli/init.d.ts +1 -1
  72. package/dist/cli/init.d.ts.map +1 -1
  73. package/dist/cli/init.js +40 -13
  74. package/dist/cli/init.js.map +1 -1
  75. package/dist/cli/sync.d.ts.map +1 -1
  76. package/dist/cli/sync.js +88 -60
  77. package/dist/cli/sync.js.map +1 -1
  78. package/dist/cli/template-files/canopy.ts.template +2 -1
  79. package/dist/cli/template-files/edit-page.tsx.template +8 -1
  80. package/dist/cli/template-files/middleware.ts.template +18 -0
  81. package/dist/cli/templates.d.ts.map +1 -1
  82. package/dist/cli/templates.js +19 -4
  83. package/dist/cli/templates.js.map +1 -1
  84. package/dist/config/helpers.d.ts +9 -1
  85. package/dist/config/helpers.d.ts.map +1 -1
  86. package/dist/config/helpers.js +33 -75
  87. package/dist/config/helpers.js.map +1 -1
  88. package/dist/config/schemas/config.d.ts +8 -24
  89. package/dist/config/schemas/config.d.ts.map +1 -1
  90. package/dist/config/schemas/config.js +12 -5
  91. package/dist/config/schemas/config.js.map +1 -1
  92. package/dist/config/schemas/media.d.ts +1 -10
  93. package/dist/config/schemas/media.d.ts.map +1 -1
  94. package/dist/config/schemas/media.js +8 -6
  95. package/dist/config/schemas/media.js.map +1 -1
  96. package/dist/config/types.d.ts +6 -4
  97. package/dist/config/types.d.ts.map +1 -1
  98. package/dist/config-test.d.ts +5 -3
  99. package/dist/config-test.d.ts.map +1 -1
  100. package/dist/config-test.js +9 -1
  101. package/dist/config-test.js.map +1 -1
  102. package/dist/content-id-index.d.ts +12 -6
  103. package/dist/content-id-index.d.ts.map +1 -1
  104. package/dist/content-id-index.js +12 -6
  105. package/dist/content-id-index.js.map +1 -1
  106. package/dist/content-index-generation.d.ts +30 -0
  107. package/dist/content-index-generation.d.ts.map +1 -0
  108. package/dist/content-index-generation.js +131 -0
  109. package/dist/content-index-generation.js.map +1 -0
  110. package/dist/content-index-registry.d.ts +38 -0
  111. package/dist/content-index-registry.d.ts.map +1 -0
  112. package/dist/content-index-registry.js +53 -0
  113. package/dist/content-index-registry.js.map +1 -0
  114. package/dist/content-listing.d.ts +3 -1
  115. package/dist/content-listing.d.ts.map +1 -1
  116. package/dist/content-listing.js +1 -0
  117. package/dist/content-listing.js.map +1 -1
  118. package/dist/content-store.d.ts +130 -6
  119. package/dist/content-store.d.ts.map +1 -1
  120. package/dist/content-store.js +343 -31
  121. package/dist/content-store.js.map +1 -1
  122. package/dist/editor/Editor.d.ts.map +1 -1
  123. package/dist/editor/Editor.js +27 -19
  124. package/dist/editor/Editor.js.map +1 -1
  125. package/dist/editor/FormRenderer.d.ts +7 -0
  126. package/dist/editor/FormRenderer.d.ts.map +1 -1
  127. package/dist/editor/FormRenderer.js +12 -3
  128. package/dist/editor/FormRenderer.js.map +1 -1
  129. package/dist/editor/components/EditorHeader.d.ts.map +1 -1
  130. package/dist/editor/components/EditorHeader.js +3 -4
  131. package/dist/editor/components/EditorHeader.js.map +1 -1
  132. package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
  133. package/dist/editor/components/EditorSidebar.js +2 -4
  134. package/dist/editor/components/EditorSidebar.js.map +1 -1
  135. package/dist/editor/fields/ReferenceField.d.ts.map +1 -1
  136. package/dist/editor/fields/ReferenceField.js +19 -2
  137. package/dist/editor/fields/ReferenceField.js.map +1 -1
  138. package/dist/editor/group-manager/hooks/useGroupState.d.ts.map +1 -1
  139. package/dist/editor/group-manager/hooks/useGroupState.js +5 -2
  140. package/dist/editor/group-manager/hooks/useGroupState.js.map +1 -1
  141. package/dist/editor/hooks/useDraftManager.d.ts +15 -0
  142. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  143. package/dist/editor/hooks/useDraftManager.js +140 -1
  144. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  145. package/dist/editor/hooks/useEntryManager.d.ts +9 -2
  146. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  147. package/dist/editor/hooks/useEntryManager.js +9 -3
  148. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  149. package/dist/editor/hooks/useSchemaManager.d.ts +34 -10
  150. package/dist/editor/hooks/useSchemaManager.d.ts.map +1 -1
  151. package/dist/editor/hooks/useSchemaManager.js +22 -25
  152. package/dist/editor/hooks/useSchemaManager.js.map +1 -1
  153. package/dist/editor/schema-editor/CollectionEditor.d.ts +6 -5
  154. package/dist/editor/schema-editor/CollectionEditor.d.ts.map +1 -1
  155. package/dist/editor/schema-editor/CollectionEditor.js +37 -12
  156. package/dist/editor/schema-editor/CollectionEditor.js.map +1 -1
  157. package/dist/entry-link-resolver.d.ts.map +1 -1
  158. package/dist/entry-link-resolver.js +5 -0
  159. package/dist/entry-link-resolver.js.map +1 -1
  160. package/dist/git-manager.d.ts +38 -3
  161. package/dist/git-manager.d.ts.map +1 -1
  162. package/dist/git-manager.js +141 -33
  163. package/dist/git-manager.js.map +1 -1
  164. package/dist/github-service.d.ts +69 -3
  165. package/dist/github-service.d.ts.map +1 -1
  166. package/dist/github-service.js +149 -25
  167. package/dist/github-service.js.map +1 -1
  168. package/dist/http/handler.d.ts +1 -1
  169. package/dist/http/handler.d.ts.map +1 -1
  170. package/dist/http/handler.js +36 -7
  171. package/dist/http/handler.js.map +1 -1
  172. package/dist/http/router.d.ts +11 -0
  173. package/dist/http/router.d.ts.map +1 -1
  174. package/dist/http/router.js +80 -16
  175. package/dist/http/router.js.map +1 -1
  176. package/dist/paths/validation.d.ts.map +1 -1
  177. package/dist/paths/validation.js +12 -0
  178. package/dist/paths/validation.js.map +1 -1
  179. package/dist/schema/schema-store.d.ts +9 -0
  180. package/dist/schema/schema-store.d.ts.map +1 -1
  181. package/dist/schema/schema-store.js +57 -8
  182. package/dist/schema/schema-store.js.map +1 -1
  183. package/dist/services.d.ts +1 -0
  184. package/dist/services.d.ts.map +1 -1
  185. package/dist/services.js +9 -2
  186. package/dist/services.js.map +1 -1
  187. package/dist/static/index.d.ts +32 -0
  188. package/dist/static/index.d.ts.map +1 -1
  189. package/dist/static/index.js +90 -0
  190. package/dist/static/index.js.map +1 -1
  191. package/dist/sync-core.d.ts.map +1 -1
  192. package/dist/sync-core.js +22 -9
  193. package/dist/sync-core.js.map +1 -1
  194. package/dist/user.d.ts +0 -18
  195. package/dist/user.d.ts.map +1 -1
  196. package/dist/user.js +7 -22
  197. package/dist/user.js.map +1 -1
  198. package/dist/utils/error.d.ts +8 -0
  199. package/dist/utils/error.d.ts.map +1 -1
  200. package/dist/utils/error.js +35 -7
  201. package/dist/utils/error.js.map +1 -1
  202. package/dist/validation/deletion-checker.d.ts.map +1 -1
  203. package/dist/validation/deletion-checker.js +6 -5
  204. package/dist/validation/deletion-checker.js.map +1 -1
  205. package/dist/validation/entry-validator.d.ts +67 -0
  206. package/dist/validation/entry-validator.d.ts.map +1 -0
  207. package/dist/validation/entry-validator.js +277 -0
  208. package/dist/validation/entry-validator.js.map +1 -0
  209. package/dist/validation/field-traversal.d.ts +23 -1
  210. package/dist/validation/field-traversal.d.ts.map +1 -1
  211. package/dist/validation/field-traversal.js +29 -9
  212. package/dist/validation/field-traversal.js.map +1 -1
  213. package/dist/worker/cms-worker.d.ts +70 -6
  214. package/dist/worker/cms-worker.d.ts.map +1 -1
  215. package/dist/worker/cms-worker.js +199 -93
  216. package/dist/worker/cms-worker.js.map +1 -1
  217. package/package.json +2 -2
  218. package/dist/editor/components/EditorContext.d.ts +0 -35
  219. package/dist/editor/components/EditorContext.d.ts.map +0 -1
  220. package/dist/editor/components/EditorContext.js +0 -19
  221. package/dist/editor/components/EditorContext.js.map +0 -1
  222. package/dist/editor/env.d.ts +0 -5
  223. package/dist/editor/env.d.ts.map +0 -1
  224. package/dist/editor/env.js +0 -5
  225. 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,EAIZ,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;AAiBhE;;;;;;;;;;;;;;;;;;;;;;;;;;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"}
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
- * Get the nested fields for a block template by looking up _type.
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 getBlockTemplateFields(blockField, item) {
12
- const blockType = item._type;
13
- if (!blockType)
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 === blockType);
17
- return template?.fields;
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 blockFields = getBlockTemplateFields(blockField, item);
85
- if (blockFields) {
86
- results.push(...traverseFields(blockFields, item, visitor, `${fieldPath}[${index}]`));
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":"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"}
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 an EFS-based lock file to prevent concurrent workers.
71
- * Uses O_CREAT|O_EXCL for atomic file creation.
72
- * Stale locks (older than 10 minutes with no running PID) are overwritten.
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 with a timeout. Uses AbortController to cancel
97
- * the underlying operation if the timeout fires.
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":"AAmBA;;;;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;CACrB;AAwBD;;;;;;;;;;GAUG;AACH,qBAAa,SAAS;IAgBR,OAAO,CAAC,MAAM;IAf1B,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,GAAG,CAAqB;gBAEZ,MAAM,EAAE,eAAe;IAarC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAmB3B;;;;OAIG;YACW,WAAW;YAoDX,WAAW;IAQzB,OAAO,CAAC,UAAU;IASlB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAkBpB;;;OAGG;YACW,eAAe;IAe7B;;;;;OAKG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAgCvC;;;OAGG;YACW,sBAAsB;YAUtB,WAAW;IAyHzB;;;OAGG;YACW,oBAAoB;IA4BlC;;;OAGG;YACW,6BAA6B;IAsB3C,OAAO,CAAC,cAAc;YAIR,kBAAkB;IAOhC;;;OAGG;YACW,oBAAoB;IAqB5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAsBhB,oBAAoB;IAwL5B,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;CAYxC"}
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 { Octokit } from '@octokit/rest';
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 { isFileExistsError } from '../utils/error.js';
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 Error(`Task payload missing required string field: ${key}`);
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 Error(`Task payload missing required number field: ${key}`);
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 = new Octokit({ auth: config.githubToken });
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 an EFS-based lock file to prevent concurrent workers.
111
- * Uses O_CREAT|O_EXCL for atomic file creation.
112
- * Stale locks (older than 10 minutes with no running PID) are overwritten.
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(path.dirname(this.lockFilePath), { recursive: true });
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
- const handle = await fs.open(this.lockFilePath, 'wx');
123
- await handle.writeFile(lockContent, 'utf-8');
124
- await handle.close();
125
- return;
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 (!isFileExistsError(err))
129
- throw err;
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 fs.unlink(this.lockFilePath);
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
- return false;
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 instanceof Error ? err.message : 'Unknown error';
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(` Permanently failed after ${maxRetries} retries`);
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 with a timeout. Uses AbortController to cancel
258
- * the underlying operation if the timeout fires.
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
- return await this.executeTask(task, controller.signal);
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
- // Check if an open PR already exists for this branch
312
- const existingPRs = await this.octokit.pulls.list({
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', `Settings update`),
421
+ title: optionalString(payload, 'title', `Submit ${branch}`),
338
422
  body: optionalString(payload, 'body', ''),
339
- request: { signal },
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(`Created PR #${newPr.data.number} for ${branch}`);
342
- return { prUrl: newPr.data.html_url, prNumber: newPr.data.number };
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 Error(`Unknown task action: ${action}`);
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({ baseDir: this.remoteGitPath });
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({ baseDir: this.remoteGitPath });
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