elanous 0.2.7 → 0.2.8

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 (252) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/4836-3084c58c1725a40b.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/8894-7317998f6cb97d8c.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/approvals/page-9aa47b0bc6d570ff.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f4d2ecbbae6088d6.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/board/page-2053e49930c82748.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-d4b6df905f185055.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-a70ba27c33003dd0.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/chat/page-186fc6952f027acc.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-18166f885d831d3e.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/control/page-1c9d6cfe68b121c2.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/design-check/page-cbbe03c0da7b081a.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/app/editor/page-c108ff1fdb665c97.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-147a328753d983e2.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-799395a9df7d95aa.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/intake/page-6a8158b71d1fe9d7.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/layout-9567560270857531.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/app/live/{page-30533117beb53625.js → page-e7ed3d010153d818.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/market/page-c0c0d19d51342d3e.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-2e002ad8faa43291.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-b672e02e4bec8deb.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/not-found-1ba4a5d857276334.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/observatory/page-9db2226f38c542fb.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-70ee65f5f7a151dc.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/reflection/page-0bb8e43203efb23b.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-092e1bcf27d751cc.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-eb52580c4b75b905.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-7464c1987dd76e6d.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/settings/{page-dd107bf1c7d52a90.js → page-e573437b0a1c728f.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-294300625e49e4d3.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/setup/page-085847b088675978.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/share/page-9fe9d38c36999eb4.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/app/showroom/page-d8ab4d1489223459.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/tasks/page-6141f67f531d8b6a.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/term/page-295a02b1026b8255.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/app/trace/page-1eb9a0e1e054209d.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/vault/{page-56adbe1a1f1379bd.js → page-82efa177a30f3741.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d0be51cec4f62606.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/workflows/page-2e367075ffdbc062.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-647ed2b66c1d49f1.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ba058c56e38f2792.js +1 -0
  47. package/apps/pwa/out/_next/static/css/5ef72a618a8994f3.css +1 -0
  48. package/apps/pwa/out/approvals/index.html +1 -1
  49. package/apps/pwa/out/approvals/index.txt +13 -13
  50. package/apps/pwa/out/autopilot/index.html +1 -1
  51. package/apps/pwa/out/autopilot/index.txt +13 -13
  52. package/apps/pwa/out/board/index.html +1 -1
  53. package/apps/pwa/out/board/index.txt +13 -13
  54. package/apps/pwa/out/botlab/index.html +1 -1
  55. package/apps/pwa/out/botlab/index.txt +13 -13
  56. package/apps/pwa/out/bots/index.html +1 -1
  57. package/apps/pwa/out/bots/index.txt +13 -13
  58. package/apps/pwa/out/chat/index.html +1 -1
  59. package/apps/pwa/out/chat/index.txt +13 -13
  60. package/apps/pwa/out/consult/index.html +1 -1
  61. package/apps/pwa/out/consult/index.txt +13 -13
  62. package/apps/pwa/out/control/index.html +1 -1
  63. package/apps/pwa/out/control/index.txt +13 -13
  64. package/apps/pwa/out/design-check/index.html +1 -1
  65. package/apps/pwa/out/design-check/index.txt +13 -13
  66. package/apps/pwa/out/editor/index.html +1 -1
  67. package/apps/pwa/out/editor/index.txt +13 -13
  68. package/apps/pwa/out/exec/index.html +1 -1
  69. package/apps/pwa/out/exec/index.txt +13 -13
  70. package/apps/pwa/out/field/index.html +1 -1
  71. package/apps/pwa/out/field/index.txt +13 -13
  72. package/apps/pwa/out/healthz +1 -1
  73. package/apps/pwa/out/index.html +1 -1
  74. package/apps/pwa/out/index.txt +13 -13
  75. package/apps/pwa/out/intake/index.html +1 -1
  76. package/apps/pwa/out/intake/index.txt +13 -13
  77. package/apps/pwa/out/live/index.html +1 -1
  78. package/apps/pwa/out/live/index.txt +13 -13
  79. package/apps/pwa/out/market/index.html +1 -1
  80. package/apps/pwa/out/market/index.txt +13 -13
  81. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  82. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  83. package/apps/pwa/out/morning/index.html +1 -1
  84. package/apps/pwa/out/morning/index.txt +13 -13
  85. package/apps/pwa/out/observatory/index.html +1 -1
  86. package/apps/pwa/out/observatory/index.txt +13 -13
  87. package/apps/pwa/out/reflection/index.html +1 -1
  88. package/apps/pwa/out/reflection/index.txt +14 -14
  89. package/apps/pwa/out/scheduler/index.html +1 -1
  90. package/apps/pwa/out/scheduler/index.txt +13 -13
  91. package/apps/pwa/out/sessions/index.html +1 -1
  92. package/apps/pwa/out/sessions/index.txt +13 -13
  93. package/apps/pwa/out/settings/devices/index.html +1 -1
  94. package/apps/pwa/out/settings/devices/index.txt +13 -13
  95. package/apps/pwa/out/settings/index.html +1 -1
  96. package/apps/pwa/out/settings/index.txt +13 -13
  97. package/apps/pwa/out/setup/done/index.html +1 -1
  98. package/apps/pwa/out/setup/done/index.txt +13 -13
  99. package/apps/pwa/out/setup/index.html +1 -1
  100. package/apps/pwa/out/setup/index.txt +13 -13
  101. package/apps/pwa/out/share/index.html +1 -1
  102. package/apps/pwa/out/share/index.txt +13 -13
  103. package/apps/pwa/out/showroom/index.html +1 -1
  104. package/apps/pwa/out/showroom/index.txt +13 -13
  105. package/apps/pwa/out/tasks/index.html +1 -1
  106. package/apps/pwa/out/tasks/index.txt +13 -13
  107. package/apps/pwa/out/term/index.html +1 -1
  108. package/apps/pwa/out/term/index.txt +13 -13
  109. package/apps/pwa/out/trace/index.html +1 -1
  110. package/apps/pwa/out/trace/index.txt +13 -13
  111. package/apps/pwa/out/vault/index.html +1 -1
  112. package/apps/pwa/out/vault/index.txt +13 -13
  113. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  114. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  115. package/apps/pwa/out/workflows/index.html +1 -1
  116. package/apps/pwa/out/workflows/index.txt +13 -13
  117. package/apps/pwa/out/workspace/index.html +1 -1
  118. package/apps/pwa/out/workspace/index.txt +13 -13
  119. package/apps/pwa/out/worktrees/index.html +1 -1
  120. package/apps/pwa/out/worktrees/index.txt +13 -13
  121. package/graphs/test-diet/recipes.yaml +9 -0
  122. package/graphs/test-diet/test-diet.yaml +22 -0
  123. package/package.json +2 -1
  124. package/persona-presets/_index.yaml +10 -0
  125. package/persona-presets/_schema.json +146 -0
  126. package/persona-presets/dana.yaml +26 -0
  127. package/persona-presets/haru.yaml +27 -0
  128. package/persona-presets/kai.yaml +27 -0
  129. package/persona-presets/leo.yaml +27 -0
  130. package/persona-presets/mira.yaml +30 -0
  131. package/persona-presets/nari.yaml +27 -0
  132. package/persona-presets/sora.yaml +27 -0
  133. package/persona-presets/yuna.yaml +27 -0
  134. package/plugins/geo-check/graphs/run-step.ts +34 -10
  135. package/scripts/field-feed/deliver-node.ts +8 -3
  136. package/scripts/field-feed/lib.ts +2 -0
  137. package/scripts/install.sh +20 -11
  138. package/scripts/persona-presets/lint.ts +79 -0
  139. package/scripts/release-loop/checklist-gate-node.ts +2 -2
  140. package/scripts/release-loop/gate-node.ts +27 -3
  141. package/scripts/release-loop/npm-publish-node.ts +10 -4
  142. package/scripts/release-loop/ops-upgrade-node.ts +11 -3
  143. package/scripts/release-loop/publish-node.ts +23 -1
  144. package/scripts/release-loop/shard-plan.ts +17 -0
  145. package/scripts/storyboard/storyboard.ts +167 -0
  146. package/scripts/test-diet/lib.ts +88 -0
  147. package/scripts/test-diet/node.ts +102 -0
  148. package/src/acp/auth-methods.ts +30 -0
  149. package/src/acp/server.ts +5 -5
  150. package/src/channel-import/detect.ts +191 -0
  151. package/src/channel-import/run.ts +111 -0
  152. package/src/cli/config-test-sync.ts +4 -0
  153. package/src/cli/decisions-cli.ts +13 -3
  154. package/src/cli/doctor-bundle.ts +128 -0
  155. package/src/cli/doctor-cli.ts +36 -1
  156. package/src/cli/doctor-local-llm.ts +95 -0
  157. package/src/cli/import-cli.ts +60 -0
  158. package/src/cli/mcp-login.ts +13 -4
  159. package/src/cli/persona-cli.ts +116 -0
  160. package/src/cli/release-cli.ts +8 -7
  161. package/src/cli/skills-cli.ts +173 -0
  162. package/src/cli/start-cli.ts +13 -4
  163. package/src/codex/setup.ts +15 -13
  164. package/src/dashboard/index.ts +17 -1
  165. package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -2
  166. package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
  167. package/src/decisions/decision-cards.ts +291 -0
  168. package/src/decisions/decision-ledger.ts +5 -2
  169. package/src/decisions/discord-decision-cards.ts +133 -0
  170. package/src/decisions/telegram-decision-cards.ts +100 -0
  171. package/src/directives/version-at.ts +77 -0
  172. package/src/discord.ts +17 -0
  173. package/src/domains/outbound-alert.ts +44 -1
  174. package/src/domains/repeated-failure.ts +1 -1
  175. package/src/error-report/report.ts +200 -0
  176. package/src/exec-requests/planner.ts +8 -2
  177. package/src/exec-requests/runner.ts +64 -24
  178. package/src/exec-requests/store.ts +2 -0
  179. package/src/field/field-feed-auto.ts +48 -0
  180. package/src/field/field-reel.ts +8 -1
  181. package/src/hooks/error-report.ts +210 -0
  182. package/src/hooks/receiver.ts +41 -1
  183. package/src/import/apply.ts +128 -0
  184. package/src/import/plan.ts +197 -0
  185. package/src/index.ts +82 -7
  186. package/src/input-core/text-entry.ts +13 -5
  187. package/src/intake-plane/seat-answer.ts +96 -0
  188. package/src/intake-plane/seat-doc-route.ts +17 -3
  189. package/src/nexus/api/consult-requests.ts +19 -8
  190. package/src/nexus/api/error-reports.ts +108 -0
  191. package/src/nexus/api/http-server.ts +21 -1
  192. package/src/nexus/api/outbound-report.ts +7 -0
  193. package/src/nexus/api/running-runs-background.ts +68 -0
  194. package/src/nexus/api/running-runs-child.ts +6 -0
  195. package/src/nexus/api/share-captures.ts +91 -0
  196. package/src/nexus/api/skill-problems.ts +56 -0
  197. package/src/nexus/api/terminals.ts +24 -8
  198. package/src/nexus/index.ts +25 -1
  199. package/src/nexus/setup-status.ts +7 -2
  200. package/src/oauth/browser-availability.ts +14 -0
  201. package/src/oauth/codex-quota-policy.ts +16 -0
  202. package/src/onboarding/continue-here.ts +13 -8
  203. package/src/onboarding/web-first.ts +8 -1
  204. package/src/onboarding.ts +66 -12
  205. package/src/persona/presets.ts +105 -0
  206. package/src/release-loop/checklist.ts +28 -5
  207. package/src/self-dev/orchestrate.ts +18 -11
  208. package/src/shell-env-bootstrap.ts +44 -7
  209. package/src/skills/connect-claude-plugin.ts +142 -0
  210. package/src/skills/connect.ts +70 -0
  211. package/src/skills/index.ts +29 -1
  212. package/src/skills/repair.ts +124 -0
  213. package/src/skills/runner.ts +11 -2
  214. package/src/skills/trigger-extract.ts +1 -1
  215. package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
  216. package/src/telegram-commands.ts +13 -0
  217. package/src/telegram.ts +34 -0
  218. package/src/user-config.ts +111 -13
  219. package/src/version/packed-revision.json +1 -1
  220. package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/app/chat/page-80497e93a878ddc5.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
  233. package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
  241. package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
  244. package/apps/pwa/out/_next/static/chunks/app/tasks/page-c1c95739c0bff3b8.js +0 -1
  245. package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
  246. package/apps/pwa/out/_next/static/chunks/app/trace/page-71d18eec33b9c667.js +0 -1
  247. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-2c4e59079ee357ac.js +0 -1
  248. package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
  249. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
  250. package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
  251. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_buildManifest.js +0 -0
  252. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_ssgManifest.js +0 -0
@@ -21,6 +21,8 @@ export interface ExecPlanItem {
21
21
  graphId: string;
22
22
  inputs: Record<string, unknown>;
23
23
  reason?: string;
24
+ /** A5b — indexes of earlier items whose results this item needs (it starts after they finish). */
25
+ after?: number[];
24
26
  }
25
27
 
26
28
  /** 설치된 플러그인의 실행 그래프 — `<state>/plugins/<market>/<name>/<version>/graphs/{recipes.yaml, <id>.yaml}`
@@ -107,7 +109,7 @@ export function seatTitles(path = fileURLToPath(new URL('../../scripts/coord-tra
107
109
  }
108
110
 
109
111
  export async function judgeExecPlan(text: string, graphs: readonly InstalledGraph[], seats: readonly string[]): Promise<unknown> {
110
- const prompt = `COO 역할. 요청을 실행 가능한 자리별 그래프로 나눠 맡겨라. JSON 배열만 출력: [{"seat":"자리","title":"할 일","graphId":"설치된 그래프 ID 또는 빈 문자열","inputs":{}}]. 자리 이름은 주어진 목록에서만, 그래프 ID는 설치된 목록에서만 고른다. 맞는 그래프가 없으면 graphId=""로 표시한다. 그래프에 inputKeys 가 있으면 inputs 를 그 키로만 채우되 한 줄에서 알 수 있는 값만 넣는다(모르는 값은 넣지 않는다). 그 그래프가 꼭 받아야 할 값(예: 브랜드 이름 · 사진 파일)을 한 줄에서 얻을 수 없으면 graphId=""로 두고 title 끝에 « — <무엇> 필요»를 적는다. 없는 그래프나 산출을 지어내지 마라. 바깥 게시·발행·광고·결제는 승인 노드를 거치기 전 실행하면 안 된다.\n자리: ${JSON.stringify(seats)}\n실행 가능한 그래프: ${JSON.stringify(graphs.map(({ id, title, description, inputKeys }) => ({ id, title, description, ...(inputKeys ? { inputKeys } : {}) })))}\n한 줄: ${JSON.stringify(text)}`;
112
+ const prompt = `COO 역할. 요청을 실행 가능한 자리별 그래프로 나눠 맡겨라. JSON 배열만 출력: [{"seat":"자리","title":"할 일","graphId":"설치된 그래프 ID 또는 빈 문자열","inputs":{},"after":[]}]. 앞 항목의 결과가 있어야 할 수 있는 일(예: 점검·조사 «결과로» 쓰는 한 장 · 보고)은 after 에 그 앞 항목 번호(0부터)를 적는다 — 그 항목은 앞 항목이 끝난 뒤 그 결과를 받아 시작한다. 서로 기다릴 필요가 없으면 after 는 빈 배열. 자리 이름은 주어진 목록에서만, 그래프 ID는 설치된 목록에서만 고른다. 맞는 그래프가 없으면 graphId=""로 표시한다. 그래프에 inputKeys 가 있으면 inputs 를 그 키로만 채우되 한 줄에서 알 수 있는 값만 넣는다(모르는 값은 넣지 않는다). 그 그래프가 꼭 받아야 할 값(예: 브랜드 이름 · 사진 파일)을 한 줄에서 얻을 수 없으면 graphId=""로 두고 title 끝에 « — <무엇> 필요»를 적는다. 없는 그래프나 산출을 지어내지 마라. 바깥 게시·발행·광고·결제는 승인 노드를 거치기 전 실행하면 안 된다.\n자리: ${JSON.stringify(seats)}\n실행 가능한 그래프: ${JSON.stringify(graphs.map(({ id, title, description, inputKeys }) => ({ id, title, description, ...(inputKeys ? { inputKeys } : {}) })))}\n한 줄: ${JSON.stringify(text)}`;
111
113
  const { streamLLM } = await import('../llm.js');
112
114
  const { tierModel } = await import('../llm/model-defaults.js');
113
115
  const raw = await streamLLM([{ role: 'user', content: prompt }], () => {}, { model: tierModel('best') });
@@ -124,16 +126,20 @@ export async function planExecRequest(text: string, deps: {
124
126
  const raw = await (deps.judge ?? judgeExecPlan)(text, graphs, seats);
125
127
  if (!Array.isArray(raw) || raw.length === 0) throw new Error('COO 계획이 비었거나 배열이 아닙니다');
126
128
  const known = new Map(graphs.map(graph => [graph.id, graph]));
127
- return raw.map((item: unknown) => {
129
+ return raw.map((item: unknown, index: number) => {
128
130
  if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error('COO 계획 항목 형식 오류');
129
131
  const row = item as Record<string, unknown>;
130
132
  if (typeof row.seat !== 'string' || !seats.includes(row.seat) || typeof row.title !== 'string' || !row.title.trim()
131
133
  || typeof row.graphId !== 'string' || !row.inputs || typeof row.inputs !== 'object' || Array.isArray(row.inputs)) {
132
134
  throw new Error('COO 계획 항목 형식 오류');
133
135
  }
136
+ const after = Array.isArray(row.after)
137
+ ? [...new Set(row.after.filter((n): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) < index))]
138
+ : [];
134
139
  return {
135
140
  seat: row.seat, title: row.title.trim(), graphId: row.graphId,
136
141
  inputs: row.inputs as Record<string, unknown>,
142
+ ...(after.length ? { after } : {}),
137
143
  ...(!known.has(row.graphId) ? { reason: `${row.seat}: 요청에 맞는 설치된 실행 그래프가 없습니다` } : {}),
138
144
  };
139
145
  });
@@ -112,6 +112,23 @@ function syncRun(item: ExecRequest, seat: ExecSeat, state: GraphRunState, root:
112
112
  if (seat.status === 'failed') seat.reason = state.nodes?.at(-1)?.error ?? `그래프 런 ${state.status}`;
113
113
  }
114
114
 
115
+ const PRIOR_TEXT_LIMIT = 4_000;
116
+
117
+ /** Earlier seats' outcome as plain context: the last summary and the head of a text/markdown report it wrote. */
118
+ export function withPriorResults(inputs: Record<string, unknown> | undefined, prior: Array<{ seat: ExecSeat; state: GraphRunState }>): Record<string, unknown> {
119
+ const parts = prior.map(({ seat, state }) => {
120
+ const outputs = [...(state.nodes ?? [])].reverse().map(node => lastJsonObject(node.output)).filter((o): o is Record<string, unknown> => !!o);
121
+ const summary = outputs.map(o => o.summary).find((v): v is string => typeof v === 'string');
122
+ const report = outputs.map(o => o.report).find((v): v is string => typeof v === 'string' && /\.(md|txt)$/i.test(v));
123
+ let body = '';
124
+ if (report && existsSync(report)) { try { body = readFileSync(report, 'utf8').slice(0, PRIOR_TEXT_LIMIT); } catch { /* summary is enough */ } }
125
+ return [`[${seat.seat} · ${seat.title}]`, summary ?? '', body].filter(Boolean).join('\n');
126
+ });
127
+ const base = inputs ?? {};
128
+ const previous = typeof base.context === 'string' && base.context.trim() ? `${base.context.trim()}\n\n` : '';
129
+ return { ...base, context: `${previous}앞 자리 결과:\n${parts.join('\n\n')}` };
130
+ }
131
+
115
132
  function aggregate(item: ExecRequest): void {
116
133
  if (item.status === 'planning' || item.seats.length === 0) return;
117
134
  if (item.seats.some(seat => seat.status === 'waiting' || seat.status === 'running')) item.status = 'running';
@@ -195,34 +212,57 @@ export class ExecRequestRunner {
195
212
  const plans = await (this.deps.plan ?? ((text: string) => planExecRequest(text, { graphs: async () => graphs })))(item.text);
196
213
  const byId = new Map(graphs.map(graph => [graph.id, graph]));
197
214
  item.seats = plans.map(plan => ({ seat: plan.seat, title: plan.title, status: plan.reason || !byId.has(plan.graphId) ? 'failed' : 'waiting', graphId: plan.graphId,
198
- runId: plan.reason || !byId.has(plan.graphId) ? '' : randomUUID(), inputs: plan.inputs,
215
+ runId: plan.reason || !byId.has(plan.graphId) ? '' : randomUUID(), inputs: plan.inputs, ...(plan.after?.length ? { after: plan.after } : {}),
199
216
  ...(plan.reason || !byId.has(plan.graphId) ? { reason: plan.reason ?? `${plan.seat}: 요청에 맞는 설치된 실행 그래프가 없습니다` } : {}) }));
200
217
  item.status = 'running';
201
218
  debug.log('exec-requests', 'planned', { id, seats: item.seats.map(seat => ({ seat: seat.seat, graphId: seat.graphId, status: seat.status })) });
202
219
  this.store.save(item);
203
- await Promise.all(item.seats.map(async (seat, index) => {
204
- const graph = byId.get(seat.graphId);
205
- if (!graph || seat.status === 'failed') return;
206
- const starting = this.store.get(id)!;
207
- starting.seats[index]!.status = 'running';
208
- this.store.save(starting);
209
- debug.log('exec-requests', 'started', { id, seat: seat.seat, graphId: seat.graphId, runId: seat.runId });
210
- debug.log('exec-requests', 'seat-status', { id, seat: seat.seat, from: 'waiting', to: 'running' });
211
- try {
212
- const state = await (this.deps.run ?? runGraph)(graph.path, { input: seat.inputs, runId: seat.runId, deps: { root: this.root } });
213
- const current = this.store.get(id)!;
214
- syncRun(current, current.seats[index]!, state, this.root);
215
- aggregate(current);
216
- this.store.save(current);
217
- } catch (error) {
218
- const current = this.store.get(id)!;
219
- current.seats[index]!.status = 'failed';
220
- current.seats[index]!.reason = error instanceof Error ? error.message : String(error);
221
- debug.log('exec-requests', 'seat-status', { id, seat: seat.seat, to: 'failed', reason: current.seats[index]!.reason });
222
- aggregate(current);
223
- this.store.save(current);
224
- }
225
- }));
220
+ // A5b — a seat that needs earlier results waits for them and starts with them in its `context`;
221
+ // seats with nothing to wait for still run together.
222
+ const states: Array<Promise<GraphRunState | null>> = [];
223
+ item.seats.forEach((seat, index) => {
224
+ states[index] = (async (): Promise<GraphRunState | null> => {
225
+ const graph = byId.get(seat.graphId);
226
+ if (!graph || seat.status === 'failed') return null;
227
+ let inputs = seat.inputs;
228
+ if (seat.after?.length) {
229
+ debug.log('exec-requests', 'waiting-on', { id, seat: seat.seat, after: seat.after });
230
+ const earlier = await Promise.all(seat.after.map(j => states[j] ?? Promise.resolve(null)));
231
+ const now = this.store.get(id)!;
232
+ const missing = seat.after.filter((j, k) => !earlier[k] || now.seats[j]?.status === 'failed');
233
+ if (missing.length) {
234
+ now.seats[index]!.status = 'failed';
235
+ now.seats[index]!.reason = `${seat.seat}: 앞 자리(${missing.map(j => now.seats[j]?.seat ?? j).join(', ')}) 결과가 없어 진행하지 않았습니다`;
236
+ debug.log('exec-requests', 'seat-status', { id, seat: seat.seat, to: 'failed', reason: 'dependency-failed' });
237
+ aggregate(now); this.store.save(now);
238
+ return null;
239
+ }
240
+ inputs = withPriorResults(seat.inputs, seat.after.map((j, k) => ({ seat: now.seats[j]!, state: earlier[k]! })));
241
+ }
242
+ const starting = this.store.get(id)!;
243
+ starting.seats[index]!.status = 'running';
244
+ this.store.save(starting);
245
+ debug.log('exec-requests', 'started', { id, seat: seat.seat, graphId: seat.graphId, runId: seat.runId, ...(seat.after?.length ? { after: seat.after } : {}) });
246
+ debug.log('exec-requests', 'seat-status', { id, seat: seat.seat, from: 'waiting', to: 'running' });
247
+ try {
248
+ const state = await (this.deps.run ?? runGraph)(graph.path, { input: inputs, runId: seat.runId, deps: { root: this.root } });
249
+ const current = this.store.get(id)!;
250
+ syncRun(current, current.seats[index]!, state, this.root);
251
+ aggregate(current);
252
+ this.store.save(current);
253
+ return current.seats[index]!.status === 'failed' ? null : state;
254
+ } catch (error) {
255
+ const current = this.store.get(id)!;
256
+ current.seats[index]!.status = 'failed';
257
+ current.seats[index]!.reason = error instanceof Error ? error.message : String(error);
258
+ debug.log('exec-requests', 'seat-status', { id, seat: seat.seat, to: 'failed', reason: current.seats[index]!.reason });
259
+ aggregate(current);
260
+ this.store.save(current);
261
+ return null;
262
+ }
263
+ })();
264
+ });
265
+ await Promise.all(states);
226
266
  const finished = this.store.get(id)!;
227
267
  aggregate(finished);
228
268
  this.store.save(finished);
@@ -12,6 +12,8 @@ export interface ExecSeat {
12
12
  runId: string;
13
13
  inputs?: Record<string, unknown>;
14
14
  reason?: string;
15
+ /** A5b — indexes of earlier seats this seat waits for. */
16
+ after?: number[];
15
17
  }
16
18
  export interface ExecResult {
17
19
  seat: string;
@@ -0,0 +1,48 @@
1
+ // EV10e — 현장 영상 렌더가 끝나면 같은 폴더로 인스타 피드 초안 런(graphs/field/field-feed.yaml)을 떼어 띄운다.
2
+ // 사람 손 없이 «사진 한 번 → 영상 ⊕ 피드 초안이 게시 대기에». 승인 전엔 아무것도 밖으로 안 나간다(그래프의 post 노드).
3
+ // 이미 «아직 안 끝난» 초안이 있으면 띄우지 않는다 — 같은 폴더에 게시 대기 카드가 둘 생기지 않게.
4
+ import { spawn as nodeSpawn } from 'node:child_process';
5
+ import { existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { debug } from '../debug/log.js';
8
+
9
+ export interface FieldFeedStart { started: boolean; reason?: 'disabled' | 'pending-draft' | 'no-script' | 'spawn-failed'; pid?: number }
10
+
11
+ export interface FieldFeedAutoDeps {
12
+ spawn?: typeof nodeSpawn;
13
+ /** 기본 = 이 파일 기준 `scripts/field-feed/start.ts`(설치본·작업 트리 둘 다 같은 상대 위치). */
14
+ script?: string;
15
+ /** 기본 = 지금 런타임(bun). */
16
+ runtime?: string;
17
+ }
18
+
19
+ /** 초안이 있고 아직 deliver 되지 않았으면(게시 대기 · 사람이 고치는 중) 새 런을 띄우지 않는다. */
20
+ export function hasPendingFeedDraft(dir: string): boolean {
21
+ const path = join(dir, 'feed', 'feed-draft.json');
22
+ if (!existsSync(path)) return false;
23
+ try { return !(JSON.parse(readFileSync(path, 'utf8')) as { delivered?: unknown }).delivered; }
24
+ catch { return true; } // 못 읽으면 «있다»로 — 덮어쓰지 않는 쪽
25
+ }
26
+
27
+ export function startFieldFeed(dir: string, opts: { enabled?: boolean } = {}, deps: FieldFeedAutoDeps = {}): FieldFeedStart {
28
+ const event = dir.split(/[\\/]/).at(-1)!;
29
+ const done = (r: FieldFeedStart): FieldFeedStart => {
30
+ debug.log('field.feed', r.started ? 'auto-start' : 'auto-skip', { event, ...(r.reason ? { reason: r.reason } : {}), ...(r.pid ? { pid: r.pid } : {}) });
31
+ return r;
32
+ };
33
+ // `bun test` 안에서는 명시적으로 켜지 않는 한 끈다 — 렌더 흐름 시험이 진짜 피드 런(그래프)을 띄우지 않게.
34
+ const testRun = process.env.NODE_ENV === 'test' && opts.enabled !== true;
35
+ if (opts.enabled === false || testRun || process.env.ELANOUS_FIELD_FEED_AUTO === '0') return done({ started: false, reason: 'disabled' });
36
+ if (hasPendingFeedDraft(dir)) return done({ started: false, reason: 'pending-draft' });
37
+ const script = deps.script ?? resolve(import.meta.dir, '../../scripts/field-feed/start.ts');
38
+ if (!existsSync(script)) return done({ started: false, reason: 'no-script' });
39
+ try {
40
+ mkdirSync(join(dir, 'feed'), { recursive: true });
41
+ const log = openSync(join(dir, 'feed', 'auto.log'), 'a');
42
+ const child = (deps.spawn ?? nodeSpawn)(deps.runtime ?? process.execPath, [script, dir], { detached: true, stdio: ['ignore', log, log] });
43
+ child.unref();
44
+ return done({ started: true, ...(child.pid ? { pid: child.pid } : {}) });
45
+ } catch {
46
+ return done({ started: false, reason: 'spawn-failed' });
47
+ }
48
+ }
@@ -5,8 +5,9 @@ import { randomUUID } from 'node:crypto';
5
5
  import { spawn, execFileSync } from 'node:child_process';
6
6
  import { debug } from '../debug/log.js';
7
7
  import { listFieldMedia } from './field-media.js';
8
+ import { startFieldFeed, type FieldFeedStart } from './field-feed-auto.js';
8
9
 
9
- export interface FieldReelResult { ok: boolean; file?: string; seconds: number; error?: string }
10
+ export interface FieldReelResult { ok: boolean; file?: string; seconds: number; error?: string; feed?: FieldFeedStart }
10
11
  export type FieldReelRunner = (dir: string, opts: { title: string; sub: string }) => Promise<FieldReelResult>;
11
12
  export interface FieldReelStatus {
12
13
  state: 'waiting' | 'rendering' | 'done' | 'failed';
@@ -26,6 +27,10 @@ export interface FieldReelOptions {
26
27
  onDone?: (result: FieldReelResult) => void | Promise<void>;
27
28
  /** 같은 수신자가 앨범 항목마다 예약해도 한 렌더당 한 번만 통지한다. */
28
29
  notificationKey?: string;
30
+ /** EV10e — 렌더 성공 뒤 인스타 피드 초안 런을 띄운다(기본 켬 · `false` 나 `ELANOUS_FIELD_FEED_AUTO=0` 이면 끔). */
31
+ autoFeed?: boolean;
32
+ /** 시험용 — 피드 시작을 바꿔 끼운다. */
33
+ startFeed?: (dir: string) => FieldFeedStart;
29
34
  }
30
35
 
31
36
  const REEL_FILE = 'reel-9x16.mp4';
@@ -160,6 +165,8 @@ async function render(dir: string, job: Job): Promise<void> {
160
165
  if (t.vision) debug.log('field.reel', 'vision', { event, ...t.vision, music: t.music === true });
161
166
  } catch { debug.log('field.reel', 'vision-unread', { event }); }
162
167
  }
168
+ // 뒤이은 업로드로 다시 렌더할 예정이면(dirty) 피드는 그 마지막 렌더 뒤에 한 번만 띄운다.
169
+ if (result.ok && !job.dirty) result = { ...result, feed: (renderOpts.startFeed ?? ((d: string) => startFieldFeed(d, { enabled: renderOpts.autoFeed })))(dir) };
163
170
  job.running = false;
164
171
  if (job.dirty) {
165
172
  writeStatus(dir, { state: 'waiting', items: listFieldMedia(dir).length });
@@ -0,0 +1,210 @@
1
+ // Error reports from users' devices (ER2 · contract v1, channel 10-01 22:16). The public bot VM receives them on
2
+ // `POST /v1/reports`, checks size, rate and schema, queues them on disk and forwards them over the tailnet to the
3
+ // Primary, which stores them for 30 days and alerts the operator. No S3 credentials live on the VM.
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ export const REPORT_MAX_BYTES = 64 * 1024;
9
+ /** Queue bounds while the Primary is unreachable: oldest reports beyond these are dropped (logged). */
10
+ export const REPORT_QUEUE_MAX = 5000;
11
+ export const REPORT_QUEUE_MAX_AGE_MS = 7 * 24 * 3600_000;
12
+
13
+ const SURFACES = ['tui', 'pwa', 'cli', 'daemon'] as const;
14
+ export interface ErrorReport {
15
+ kind: 'error-report';
16
+ v: 1;
17
+ code: string;
18
+ message: string;
19
+ stack?: string;
20
+ app: { version: string; sha: string; surface: (typeof SURFACES)[number]; os: string; arch: string };
21
+ nexus: { alive: boolean; version?: string; instance?: string };
22
+ who: { installId: string; email?: string; telegramUser?: string };
23
+ at: string;
24
+ consent: true;
25
+ }
26
+ /** What the VM forwards: the report plus what the receiver derived. */
27
+ export interface ReceivedReport { reportId: string; dedupe: string; receivedAt: string; report: ErrorReport }
28
+
29
+ const str = (v: unknown, max: number): string | undefined => (typeof v === 'string' && v.length > 0 && v.length <= max ? v : undefined);
30
+ const obj = (v: unknown): Record<string, unknown> | undefined => (v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : undefined);
31
+
32
+ /** Strict schema: unknown fields are dropped, a bad required field names itself. */
33
+ export function validateErrorReport(body: unknown): { ok: true; report: ErrorReport } | { ok: false; field: string } {
34
+ const b = obj(body);
35
+ if (!b) return { ok: false, field: 'body' };
36
+ if (b.kind !== 'error-report') return { ok: false, field: 'kind' };
37
+ if (b.v !== 1) return { ok: false, field: 'v' };
38
+ if (b.consent !== true) return { ok: false, field: 'consent' };
39
+ const code = typeof b.code === 'string' && /^[a-z0-9-]{1,64}$/.test(b.code) ? b.code : undefined;
40
+ if (!code) return { ok: false, field: 'code' };
41
+ const message = str(b.message, 2000);
42
+ if (!message) return { ok: false, field: 'message' };
43
+ if (b.stack !== undefined && !str(b.stack, 8000)) return { ok: false, field: 'stack' };
44
+ const app = obj(b.app);
45
+ const version = str(app?.version, 64);
46
+ const sha = str(app?.sha, 64);
47
+ const surface = SURFACES.find((s) => s === app?.surface);
48
+ const os = str(app?.os, 64);
49
+ const arch = str(app?.arch, 32);
50
+ if (!version || !sha || !surface || !os || !arch) return { ok: false, field: 'app' };
51
+ const nexus = obj(b.nexus);
52
+ if (!nexus || typeof nexus.alive !== 'boolean') return { ok: false, field: 'nexus' };
53
+ const who = obj(b.who);
54
+ const installId = typeof who?.installId === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(who.installId) ? who.installId : undefined;
55
+ if (!installId) return { ok: false, field: 'who.installId' };
56
+ const at = typeof b.at === 'string' && Number.isFinite(Date.parse(b.at)) ? b.at : undefined;
57
+ if (!at) return { ok: false, field: 'at' };
58
+ // nexus.instance is accepted only as a hash — a raw instance name can carry a host name.
59
+ const instance = typeof nexus.instance === 'string' && /^[0-9a-f]{12,64}$/.test(nexus.instance) ? nexus.instance : undefined;
60
+ const email = str(who?.email, 254);
61
+ const telegramUser = str(who?.telegramUser, 64);
62
+ return {
63
+ ok: true,
64
+ report: {
65
+ kind: 'error-report', v: 1, code, message, ...(typeof b.stack === 'string' ? { stack: b.stack } : {}),
66
+ app: { version, sha, surface, os, arch },
67
+ nexus: { alive: nexus.alive, ...(str(nexus.version, 64) ? { version: nexus.version as string } : {}), ...(instance ? { instance } : {}) },
68
+ who: { installId, ...(email ? { email } : {}), ...(telegramUser ? { telegramUser } : {}) },
69
+ at, consent: true,
70
+ },
71
+ };
72
+ }
73
+
74
+ /** Same error = same code and same first stack frame. Computed by the receiver, never sent by the device. */
75
+ export function reportDedupe(report: Pick<ErrorReport, 'code' | 'stack'>): string {
76
+ const frame = (report.stack ?? '').split('\n').map((l) => l.trim()).find((l) => l.length > 0 && l !== report.code) ?? '';
77
+ return createHash('sha256').update(`${report.code}\n${frame}`).digest('hex').slice(0, 12);
78
+ }
79
+
80
+ const B32 = '0123456789abcdefghjkmnpqrstvwxyz';
81
+ /** `er_` + 26 chars: 10 time chars then 16 random — sortable by receipt time. */
82
+ export function newReportId(now: number = Date.now()): string {
83
+ let t = '';
84
+ let n = now;
85
+ for (let i = 0; i < 10; i++) { t = B32[n % 32]! + t; n = Math.floor(n / 32); }
86
+ const r = [...randomBytes(16)].map((x) => B32[x % 32]).join('');
87
+ return `er_${t}${r}`;
88
+ }
89
+
90
+ /** In-memory sliding windows: per install 5/min and 20/day, per IP 60/hour; same (install, dedupe) stored once an hour. */
91
+ export class ReportLimiter {
92
+ private readonly hits = new Map<string, number[]>();
93
+ private readonly stored = new Map<string, number>();
94
+ private readonly seen = new Map<string, number[]>();
95
+ constructor(private readonly now: () => number = Date.now) {}
96
+
97
+ private take(key: string, windowMs: number, max: number): number | null {
98
+ const t = this.now();
99
+ const list = (this.hits.get(key) ?? []).filter((x) => t - x < windowMs);
100
+ if (list.length >= max) { this.hits.set(key, list); return Math.ceil((windowMs - (t - list[0]!)) / 1000); }
101
+ list.push(t);
102
+ this.hits.set(key, list);
103
+ return null;
104
+ }
105
+
106
+ /** Returns seconds to wait when limited, otherwise null (and records the hit). */
107
+ admit(installId: string, ip: string): number | null {
108
+ return this.take(`ip:${ip}`, 3600_000, 60)
109
+ ?? this.take(`im:${installId}`, 60_000, 5)
110
+ ?? this.take(`id:${installId}`, 24 * 3600_000, 20);
111
+ }
112
+
113
+ /** Counts this occurrence; true when it should be stored (first of this install+dedupe within an hour). */
114
+ occurrence(installId: string, dedupe: string): { store: boolean; count24h: number } {
115
+ const t = this.now();
116
+ const list = (this.seen.get(dedupe) ?? []).filter((x) => t - x < 24 * 3600_000);
117
+ list.push(t);
118
+ this.seen.set(dedupe, list);
119
+ const key = `${installId}:${dedupe}`;
120
+ const last = this.stored.get(key);
121
+ const store = last === undefined || t - last >= 3600_000;
122
+ if (store) this.stored.set(key, t);
123
+ return { store, count24h: list.length };
124
+ }
125
+ }
126
+
127
+ /** On-disk queue on the VM: one file per report, atomic writes, bounded by count and age. */
128
+ export class ReportQueue {
129
+ readonly directory: string;
130
+ constructor(root: string, private readonly log: (event: string, data: Record<string, unknown>) => void = () => {}) {
131
+ this.directory = join(root, 'hooks', 'reports');
132
+ mkdirSync(this.directory, { recursive: true, mode: 0o700 });
133
+ }
134
+ private path(reportId: string): string { return join(this.directory, `${reportId}.json`); }
135
+ enqueue(item: ReceivedReport): void {
136
+ const path = this.path(item.reportId);
137
+ const temp = `${path}.${randomBytes(6).toString('hex')}.tmp`;
138
+ try {
139
+ const fd = openSync(temp, 'wx', 0o600);
140
+ try { writeSync(fd, JSON.stringify(item)); fsyncSync(fd); } finally { closeSync(fd); }
141
+ renameSync(temp, path);
142
+ } finally { rmSync(temp, { force: true }); }
143
+ }
144
+ /** Oldest first; drops entries past the age or count bound (logged, never silent). */
145
+ entries(now: number = Date.now()): ReceivedReport[] {
146
+ const names = readdirSync(this.directory).filter((n) => /^er_[0-9a-z]{26}\.json$/.test(n)).sort();
147
+ const kept: ReceivedReport[] = [];
148
+ let expired = 0;
149
+ for (const name of names) {
150
+ const full = join(this.directory, name);
151
+ try {
152
+ const item = JSON.parse(readFileSync(full, 'utf8')) as ReceivedReport;
153
+ if (now - Date.parse(item.receivedAt) > REPORT_QUEUE_MAX_AGE_MS) { rmSync(full, { force: true }); expired++; continue; }
154
+ kept.push(item);
155
+ } catch { rmSync(full, { force: true }); }
156
+ }
157
+ const overflow = kept.length - REPORT_QUEUE_MAX;
158
+ if (overflow > 0) for (const item of kept.splice(0, overflow)) rmSync(this.path(item.reportId), { force: true });
159
+ if (expired || overflow > 0) this.log('dropped', { expired, overflow: Math.max(0, overflow) });
160
+ return kept;
161
+ }
162
+ count(): number { return readdirSync(this.directory).filter((n) => /^er_[0-9a-z]{26}\.json$/.test(n)).length; }
163
+ delivered(reportId: string): void { rmSync(this.path(reportId), { force: true }); }
164
+ }
165
+
166
+ /** Handles `POST /v1/reports` on the VM. Pure enough to test without a server. */
167
+ export async function handleReportPost(request: Request, deps: {
168
+ limiter: ReportLimiter; queue: ReportQueue; now?: () => number; ip?: string;
169
+ log?: (event: string, data: Record<string, unknown>) => void; wake?: () => void;
170
+ }): Promise<Response> {
171
+ const now = deps.now ?? Date.now;
172
+ const log = deps.log ?? (() => {});
173
+ const declared = Number(request.headers.get('content-length') ?? '0');
174
+ if (declared > REPORT_MAX_BYTES) { log('rejected', { reason: 'too-large' }); return Response.json({ error: 'too-large' }, { status: 413 }); }
175
+ const raw = await request.arrayBuffer();
176
+ if (raw.byteLength > REPORT_MAX_BYTES) { log('rejected', { reason: 'too-large' }); return Response.json({ error: 'too-large' }, { status: 413 }); }
177
+ let body: unknown;
178
+ try { body = JSON.parse(new TextDecoder().decode(raw)); } catch { return Response.json({ error: 'invalid', field: 'body' }, { status: 400 }); }
179
+ const checked = validateErrorReport(body);
180
+ if (!checked.ok) { log('rejected', { reason: 'invalid', field: checked.field }); return Response.json({ error: 'invalid', field: checked.field }, { status: 400 }); }
181
+ const report = checked.report;
182
+ const ip = deps.ip ?? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
183
+ const wait = deps.limiter.admit(report.who.installId, ip);
184
+ if (wait !== null) {
185
+ log('rejected', { reason: 'rate-limited' });
186
+ return Response.json({ error: 'rate-limited', retryAfterSec: wait }, { status: 429, headers: { 'Retry-After': String(wait) } });
187
+ }
188
+ const dedupe = reportDedupe(report);
189
+ const reportId = newReportId(now());
190
+ const { store, count24h } = deps.limiter.occurrence(report.who.installId, dedupe);
191
+ if (store) {
192
+ deps.queue.enqueue({ reportId, dedupe, receivedAt: new Date(now()).toISOString(), report });
193
+ deps.wake?.();
194
+ }
195
+ log('accepted', { code: report.code, surface: report.app.surface, stored: store, count24h });
196
+ return Response.json({ reportId, dedupe, count: count24h }, { status: 202 });
197
+ }
198
+
199
+ /** Forward queued reports to the Primary; delivered ones leave the queue, failures wait for the next drain. */
200
+ export async function drainReports(queue: ReportQueue, forward: (item: ReceivedReport) => Promise<boolean>, now: number = Date.now()): Promise<{ delivered: number; failed: number }> {
201
+ let delivered = 0;
202
+ let failed = 0;
203
+ for (const item of queue.entries(now)) {
204
+ let ok = false;
205
+ try { ok = await forward(item); } catch { ok = false; }
206
+ if (ok) { queue.delivered(item.reportId); delivered++; } else { failed++; break; }
207
+ }
208
+ return { delivered, failed };
209
+ }
210
+
@@ -4,6 +4,8 @@ import { userConfigPath } from '../user-config.js';
4
4
  import { getSecretAsync } from '../nexus/config/secrets/index.js';
5
5
  import { HookQueue, type QueuedHook } from './queue.js';
6
6
  import { toExternalTask, verifyWebhook, type HookProvider } from './providers.js';
7
+ import { drainReports, handleReportPost, ReportLimiter, ReportQueue, type ReceivedReport } from './error-report.js';
8
+ import { effectiveInstanceRoot } from '../instance/resolve.js';
7
9
 
8
10
  export interface HookSecrets {
9
11
  linear?: string;
@@ -18,6 +20,8 @@ export interface HookReceiverOptions {
18
20
  now?: () => number;
19
21
  root?: string;
20
22
  retryBaseMs?: number;
23
+ /** ER2 — forward one queued error report to the Primary; default = POST /v1/reports/ingest over the tailnet. */
24
+ forwardReport?: (item: ReceivedReport) => Promise<boolean>;
21
25
  }
22
26
 
23
27
  /** `/v1/tasks` lives on the Primary's nexus API, not on the control plane — so the address is its own setting. */
@@ -42,8 +46,40 @@ async function forwardToPrimary(event: QueuedHook): Promise<Response> {
42
46
  });
43
47
  }
44
48
 
49
+ async function forwardReportToPrimary(item: ReceivedReport): Promise<boolean> {
50
+ const config = JSON.parse(readFileSync(userConfigPath(), 'utf8')) as { hooks?: { primaryTokenRef?: string; primaryUrl?: unknown } };
51
+ const ref = config.hooks?.primaryTokenRef;
52
+ if (!ref) throw new Error('primary token reference missing');
53
+ const token = await getSecretAsync(ref);
54
+ if (!token) throw new Error('primary token missing');
55
+ const response = await fetch(new URL('/v1/reports/ingest', hooksPrimaryUrl(config)), {
56
+ method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
57
+ body: JSON.stringify(item), signal: AbortSignal.timeout(10_000),
58
+ });
59
+ return response.ok;
60
+ }
61
+
45
62
  export function startHookReceiver(options: HookReceiverOptions): { url: string; stop: () => void; queue: HookQueue } {
46
63
  const queue = new HookQueue(options.root);
64
+ const reportQueue = new ReportQueue(options.root ?? effectiveInstanceRoot(), (event, data) => debug.log('hooks.reports', event, data));
65
+ const reportLimiter = new ReportLimiter(options.now ?? Date.now);
66
+ const forwardReport = options.forwardReport ?? forwardReportToPrimary;
67
+ let reportFailures = 0;
68
+ let reportNextAt = 0;
69
+ let reportsDraining = false;
70
+ async function drainReportQueue(): Promise<void> {
71
+ if (stopped || reportsDraining || Date.now() < reportNextAt) return;
72
+ reportsDraining = true;
73
+ try {
74
+ const result = await drainReports(reportQueue, forwardReport, (options.now ?? Date.now)());
75
+ if (result.delivered) debug.log('hooks.reports', 'forwarded', { delivered: result.delivered });
76
+ if (result.failed) {
77
+ reportFailures++;
78
+ reportNextAt = Date.now() + Math.min(300_000, Math.max(1, options.retryBaseMs ?? 1000) * 2 ** Math.min(reportFailures - 1, 20));
79
+ debug.log('hooks.reports', 'forward-failed', { queued: reportQueue.count(), failures: reportFailures });
80
+ } else reportFailures = 0;
81
+ } finally { reportsDraining = false; }
82
+ }
47
83
  const now = options.now ?? Date.now;
48
84
  const forward = options.forward ?? forwardToPrimary;
49
85
  const attempts = new Map<string, { next: number; failures: number }>();
@@ -75,6 +111,7 @@ export function startHookReceiver(options: HookReceiverOptions): { url: string;
75
111
  })();
76
112
  }
77
113
  } finally {
114
+ void drainReportQueue();
78
115
  draining = false;
79
116
  if (!stopped) timer = setTimeout(() => { void drain(); }, Math.min(1000, options.retryBaseMs ?? 1000));
80
117
  }
@@ -83,7 +120,10 @@ export function startHookReceiver(options: HookReceiverOptions): { url: string;
83
120
  async fetch(request) {
84
121
  const url = new URL(request.url);
85
122
  if (request.method === 'GET' && url.pathname === '/hooks/health')
86
- return Response.json({ ok: true, queued: queue.count() });
123
+ return Response.json({ ok: true, queued: queue.count(), reportsQueued: reportQueue.count() });
124
+ if (request.method === 'POST' && url.pathname === '/v1/reports')
125
+ return handleReportPost(request, { limiter: reportLimiter, queue: reportQueue, ...(options.now ? { now: options.now } : {}),
126
+ log: (event, data) => debug.log('hooks.reports', event, data), wake: () => { reportNextAt = 0; void drainReportQueue(); } });
87
127
  if (request.method !== 'POST' || !/^\/hooks\/(linear|asana)$/.test(url.pathname)) return new Response('Not Found', { status: 404 });
88
128
  const provider = url.pathname.slice('/hooks/'.length) as HookProvider;
89
129
  const reject = (reason: string) => {