@starci/hfs 4.0.10 → 4.2.0

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 (275) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +65 -24
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +194 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +222 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/release-config.mjs +28 -0
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +35 -31
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +161 -65
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
  30. package/runtime/modules/models/registry.yaml +25 -359
  31. package/runtime/modules/models/runtimes.yaml +126 -265
  32. package/runtime/modules/models/tiers.yaml +66 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  37. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  38. package/runtime/scripts/api/git/lib.mjs +34 -1
  39. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  40. package/runtime/scripts/api/process/run-program.mjs +2 -1
  41. package/runtime/scripts/api/sops/lib.mjs +198 -120
  42. package/runtime/scripts/hfs/allows.mjs +3 -3
  43. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  44. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  45. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  46. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  47. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  48. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  49. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  50. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  51. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  52. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  53. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  54. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  55. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  56. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  57. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  58. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  59. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  60. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  61. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  62. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  63. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  64. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  65. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  66. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  67. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  68. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  69. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  70. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  72. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  74. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  75. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  76. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  77. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  78. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  79. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  80. package/runtime/scripts/hfs/architecture/index.mjs +164 -104
  81. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  85. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  86. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  88. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  89. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  90. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  91. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  92. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  93. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  94. package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
  95. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  96. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  97. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  98. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  99. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  100. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  101. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  102. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  103. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  104. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  107. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  108. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  109. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  110. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  111. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  112. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  113. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  114. package/runtime/scripts/hfs/architecture.mjs +3 -4
  115. package/runtime/scripts/hfs/catalog-once.mjs +15 -0
  116. package/runtime/scripts/hfs/check.mjs +63 -71
  117. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  118. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  119. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  120. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  121. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  122. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  123. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  124. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  125. package/runtime/scripts/hfs/repo-identity.mjs +21 -4
  126. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  127. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  128. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  129. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  130. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  131. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  132. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  133. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  134. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  135. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  136. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  137. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  138. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  139. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  140. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  141. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  142. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  143. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  144. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  145. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  146. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  147. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  148. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  149. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  150. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  151. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  152. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  153. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  154. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  155. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  156. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  157. package/runtime/scripts/hfs/secret.mjs +59 -48
  158. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  159. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  160. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  161. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  162. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  163. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  164. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  165. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  166. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  167. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  168. package/runtime/scripts/hfs/slots.mjs +74 -695
  169. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  170. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  171. package/runtime/scripts/hfs/tree.mjs +19 -14
  172. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  173. package/runtime/scripts/hfs/view.mjs +1 -1
  174. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  175. package/runtime/scripts/lib/env.mjs +10 -0
  176. package/runtime/scripts/lib/event-contract.mjs +17 -13
  177. package/runtime/scripts/lib/git.mjs +2 -2
  178. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  179. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  180. package/runtime/scripts/lib/i18n.mjs +3 -2
  181. package/runtime/scripts/lib/in-order.mjs +71 -0
  182. package/runtime/scripts/lib/language.mjs +3 -3
  183. package/runtime/scripts/lib/list.mjs +8 -1
  184. package/runtime/scripts/lib/path-key.mjs +38 -5
  185. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  186. package/runtime/scripts/lib/regex.mjs +1 -1
  187. package/runtime/scripts/lib/same-text.mjs +1 -1
  188. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  189. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  190. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  191. package/runtime/scripts/lib/stack-services.mjs +1 -2
  192. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  193. package/runtime/scripts/lib/walk.mjs +6 -1
  194. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
  195. package/scaffold/add-cli-lite.mjs +2 -1
  196. package/scaffold/add-table.mjs +2 -2
  197. package/scaffold/add.mjs +3 -2
  198. package/scaffold/app.mjs +4 -2
  199. package/scaffold/edition-gate.mjs +25 -25
  200. package/scaffold/lite-exports.mjs +2 -1
  201. package/scaffold/service.mjs +5 -4
  202. package/sync/hygiene.mjs +15 -9
  203. package/sync/index.mjs +7 -3
  204. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  205. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  206. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  207. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  208. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  209. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  210. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  211. package/templates/app/starciwork.gitignore +1 -1
  212. package/templates/be/image/api/Dockerfile +1 -1
  213. package/templates/be/image/cli/Dockerfile +1 -1
  214. package/templates/be/image/worker/Dockerfile +1 -1
  215. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  216. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  217. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  218. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  219. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  220. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  221. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  222. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  223. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  224. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  225. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  226. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  228. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  229. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  230. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  231. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  232. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  233. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  234. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  235. package/templates/fe/image/next/Dockerfile +2 -1
  236. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  238. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  239. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  242. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  243. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  245. package/upgrade/index.mjs +9 -2
  246. package/runtime/engine/admission.mjs +0 -308
  247. package/runtime/engine/canonical-json.mjs +0 -10
  248. package/runtime/engine/db/blob.mjs +0 -315
  249. package/runtime/engine/db/ledger-paths.mjs +0 -83
  250. package/runtime/engine/db/ledger.mjs +0 -1118
  251. package/runtime/engine/db/machine-connection.mjs +0 -135
  252. package/runtime/engine/db/machine-schema.mjs +0 -84
  253. package/runtime/engine/db/machine.mjs +0 -1367
  254. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  255. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  256. package/runtime/engine/db/provider-reservations.mjs +0 -101
  257. package/runtime/engine/digest.mjs +0 -16
  258. package/runtime/engine/refuse.mjs +0 -11
  259. package/runtime/modules/ops/_labels.yaml +0 -53
  260. package/runtime/scripts/api/node/lib.mjs +0 -14
  261. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  262. package/runtime/scripts/api/process/lib.mjs +0 -111
  263. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  264. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  265. package/runtime/scripts/connectors/lib.mjs +0 -488
  266. package/runtime/scripts/lib/clip.mjs +0 -19
  267. package/runtime/scripts/lib/display-names.mjs +0 -259
  268. package/runtime/scripts/lib/example-refs.mjs +0 -158
  269. package/runtime/scripts/lib/json-schema.mjs +0 -52
  270. package/runtime/scripts/lib/process-identity.mjs +0 -6
  271. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  272. package/runtime/scripts/lib/redact.mjs +0 -161
  273. package/runtime/scripts/lib/sleep.mjs +0 -7
  274. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  275. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -1,488 +0,0 @@
1
- // scripts/connectors/lib.mjs — what the ask gateway, the tunnel manager and the
2
- // Telegram notifier share: the host locks every singleton manager claims, the
3
- // connectors rows (machine.sqlite `connectors`: gateway, tunnel,
4
- // telegram-bridge, telegram-route, supervisor-channel:<id>), the repositories
5
- // whose asks go public, the read-only projection of open ask forms, and the
6
- // small process helpers. docs/connectors.md is the design note.
7
- //
8
- // Every ledger read here goes through inspectLedger (read-only): the
9
- // connectors observe asks, they never write a ledger. Every piece of host state
10
- // lives in machine.sqlite (engine/db/machine.mjs): a manager lock is a
11
- // host_locks row, a launch still starting is that row in state 'starting'.
12
- import fs from 'node:fs';
13
- import os from 'node:os';
14
- import path from 'node:path';
15
- import { spawnNode } from '../api/node/spawn-node.mjs';
16
- import { inspectLedger, ledgerFileFor, isRuntimeRoot, hasLedger } from '../../engine/db/ledger.mjs';
17
- import { machineLog, pidAlive, readMachine, withMachine } from '../../engine/db/machine.mjs';
18
- import { skillRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
19
- import { loadConfig } from '../../engine/config.mjs';
20
- import { parseJson, readJsonFile } from '../lib/json.mjs';
21
- import { jobDisplayNameOf, workflowNameOf } from '../lib/display-names.mjs';
22
- import { sleep } from '../lib/sleep.mjs';
23
- import { OWNED_PROCESS_SCHEMA } from '../lib/process-identity.mjs';
24
- import { stopOwnedProcess } from '../api/process/stop-owned-process.mjs';
25
- import { canonicalJSON } from '../../engine/canonical-json.mjs';
26
-
27
-
28
- /** When this host last booted (ms). */
29
- const hostBootAt = () => Date.now() - os.uptime() * 1000;
30
- /**
31
- * Whether the process a state record names ({pid, startedAt}) is still that process: its pid is
32
- * live AND it started in this boot. After a reboot the recorded pid may name an unrelated process,
33
- * and a connector that trusted it would never start again. `startedAt` is an ISO time or epoch ms.
34
- */
35
- export const recordAlive = (record) => {
36
- if (!record?.pid || !pidAlive(record.pid)) return false;
37
- const started = typeof record.startedAt === 'number' ? record.startedAt : Date.parse(record.startedAt ?? '');
38
- return !Number.isFinite(started) || started >= hostBootAt() - 60_000;
39
- };
40
-
41
- /* ------------------------------------------------------------ host locks (machine.sqlite host_locks) */
42
-
43
- /**
44
- * How long a held manager lock stays unexpired without a renewal. The holder renews it every LOCK_RENEW_MS from an
45
- * unref'd timer, so an expired lock (v_leaks) is one whose holder stopped renewing. Who holds a lock is decided by its
46
- * holder pid (recordAlive: live and of this boot), never by the expiry: a busy holder that missed a renewal keeps it.
47
- */
48
- const LOCK_TTL_MS = 10 * 60_000;
49
- const LOCK_RENEW_MS = 3 * 60_000;
50
- const holderLabel = () => (process.argv[1] ? path.basename(process.argv[1]) : 'node');
51
- /** A host_locks row as the record callers read: {pid, startedAt (ISO), at, handedOverFrom, state, holder}. */
52
- const lockRecord = (row) => (row ? { pid: row.holder_pid, startedAt: new Date(row.started_at).toISOString(), at: row.started_at,
53
- handedOverFrom: row.handed_over_from ?? null, state: row.state, holder: row.holder ?? null } : null);
54
- const liveRow = (row) => Boolean(row) && row.state !== 'released' && recordAlive({ pid: row.holder_pid, startedAt: row.started_at });
55
-
56
- // The renewal timers of the locks this process holds, by name.
57
- const renewals = new Map();
58
- const stopRenewal = (name) => { clearInterval(renewals.get(name)); renewals.delete(name); };
59
- function startRenewal(name, env) {
60
- stopRenewal(name);
61
- const timer = setInterval(() => {
62
- let still = true;
63
- try { still = withMachine((m) => m.renewHostLock({ name, ttlMs: LOCK_TTL_MS }), { env }); } catch { /* a busy store: the next tick tries again */ }
64
- if (!still) stopRenewal(name);
65
- }, LOCK_RENEW_MS);
66
- timer.unref?.();
67
- renewals.set(name, timer);
68
- }
69
- /** The handle a successful claim returns: release() frees the lock only while it still names this process. */
70
- function heldBy(name, env, extra = {}) {
71
- startRenewal(name, env);
72
- const release = () => {
73
- stopRenewal(name);
74
- try { withMachine((m) => m.releaseHostLock({ name }), { env }); } catch { /* the store is gone: nothing left to free */ }
75
- };
76
- return { ok: true, release, name, ...extra };
77
- }
78
-
79
- /**
80
- * Take `name` for this process inside one transaction on `m`: refused while another live process of this boot holds
81
- * it (state 'held'); a dead holder, one from an earlier boot, or a launch still 'starting' is replaced. Returns
82
- * {ok:true} or {ok:false, holder}.
83
- */
84
- function takeLock(m, name) {
85
- return m.transaction(() => {
86
- const custody = recordOf(m.connectorOf(name));
87
- if (connectorManagerBlocked(custody)) return { ok: false, holder: custody, reason: 'connector-child-custody-unreconciled' };
88
- const cur = m.hostLock(name);
89
- if (cur && cur.state === 'held' && cur.holder_pid !== process.pid && liveRow(cur)) return { ok: false, holder: lockRecord(cur) };
90
- if (cur && cur.state !== 'released' && cur.holder_pid !== process.pid) m.releaseHostLock({ name, force: true });
91
- const got = m.acquireHostLock({ name, holder: holderLabel(), ttlMs: LOCK_TTL_MS, state: 'held' });
92
- return got.ok ? { ok: true } : { ok: false, holder: lockRecord(got.holder) };
93
- });
94
- }
95
-
96
- /**
97
- * Claim the single-manager host lock `name` for this process. `current` is the manager's own record (its connectors
98
- * row: gateway, tunnel): a live one that is not this process owns the state even without the lock. A lock whose
99
- * holder is dead or from an earlier boot is stale and taken over. Returns {ok:true, release} or {ok:false, holder}.
100
- */
101
- export function claimManager(name, { current = null, env = process.env } = {}) {
102
- if (connectorManagerBlocked(current)) return { ok: false, holder: current, reason: 'connector-child-custody-unreconciled' };
103
- if (current && current.pid !== process.pid && recordAlive(current)) return { ok: false, holder: current };
104
- const got = withMachine((m) => takeLock(m, name), { env });
105
- return got.ok ? heldBy(name, env) : { ok: false, holder: got.holder ?? null, ...(got.reason ? { reason: got.reason } : {}) };
106
- }
107
-
108
- /**
109
- * Claim a manager lock that may be handed over (scripts/machine/self-reload.mjs): a loop that re-execs itself
110
- * spawns its replacement with `from` = its own pid and waits, still holding the lock, until the lock names
111
- * the replacement. The replacement takes the row over only while it still names `from`, in one transaction,
112
- * so there is no moment the lock is free for a third claimant. Without `from`, or when the lock no longer
113
- * names it, this is claimManager. Returns {ok:true, release, takenOver?} or {ok:false, holder}.
114
- */
115
- export function claimOrTakeOver(name, { from = null, env = process.env } = {}) {
116
- const fromPid = Number(from);
117
- if (Number.isInteger(fromPid) && fromPid > 0) {
118
- const took = withMachine((m) => m.transaction(() => {
119
- if (connectorManagerBlocked(recordOf(m.connectorOf(name)))) return false;
120
- const cur = m.hostLock(name);
121
- if (!cur || cur.state === 'released' || cur.holder_pid !== fromPid) return false;
122
- const at = m.now();
123
- return m.update('host_locks', { holder_pid: process.pid, holder: holderLabel(), started_at: at, heartbeat_at: at, expires_at: at + LOCK_TTL_MS,
124
- handed_over_from: fromPid, state: 'held' }, { name, holder_pid: fromPid }).changes > 0;
125
- }), { env });
126
- if (took) return heldBy(name, env, { takenOver: true });
127
- }
128
- return claimManager(name, { env });
129
- }
130
-
131
- /** Make the lock name this process again (a handover that failed after the replacement took it). */
132
- export const reassertManager = (name, { env = process.env } = {}) => {
133
- const ok = withMachine((m) => m.transaction(() => {
134
- const cur = m.hostLock(name);
135
- if (cur && cur.state !== 'released' && cur.holder_pid !== process.pid) m.releaseHostLock({ name, force: true });
136
- return m.acquireHostLock({ name, holder: holderLabel(), ttlMs: LOCK_TTL_MS, state: 'held' }).ok;
137
- }), { env });
138
- if (ok) startRenewal(name, env);
139
- return ok;
140
- };
141
-
142
- /** The live holder of a manager lock ({pid, startedAt, handedOverFrom, ...}), or null. A launch still starting is not one. */
143
- export const lockHolder = (name, env = process.env) => {
144
- const row = readMachine((m) => m.hostLock(name), null, { env });
145
- return row?.state === 'held' && liveRow(row) ? lockRecord(row) : null;
146
- };
147
-
148
- /**
149
- * A manager a starter just launched but that has not claimed its lock yet (node takes a while to
150
- * start on a loaded host). `start` marks the lock row 'starting' with the launched pid right after the
151
- * spawn, and every liveness test counts it for STARTING_MS while that pid lives, so two starters in
152
- * that window do not both launch a manager. The launched manager's claim turns the row 'held'.
153
- */
154
- const STARTING_MS = 30_000;
155
- export const markStarting = (name, pid, env = process.env) => {
156
- if (!Number.isInteger(pid) || pid <= 0) return false;
157
- return withMachine((m) => m.transaction(() => {
158
- const cur = m.hostLock(name);
159
- // A live holder (the launched manager already claimed, or another one) is never overwritten.
160
- if (cur && cur.state === 'held' && liveRow(cur)) return false;
161
- if (cur && cur.state !== 'released' && cur.holder_pid !== pid) m.releaseHostLock({ name, force: true });
162
- return m.acquireHostLock({ name, holder: 'starting', pid, ttlMs: STARTING_MS, state: 'starting' }).ok;
163
- }), { env });
164
- };
165
- export const startingHolder = (name, env = process.env, { windowMs = STARTING_MS, now = Date.now() } = {}) => {
166
- const row = readMachine((m) => m.hostLock(name), null, { env });
167
- return row?.state === 'starting' && now - row.started_at < windowMs && pidAlive(row.holder_pid)
168
- ? { pid: row.holder_pid, at: row.started_at, startedAt: new Date(row.started_at).toISOString() } : null;
169
- };
170
-
171
- /**
172
- * Run async `fn` while holding the host lock `name` (a short cross-process mutex: the Telegram notice store, the
173
- * media dedupe). Waits up to `waitMs`, then resolves {ok:false, skipped} without running `fn`. Calls from one
174
- * process are chained, since a host lock is per pid.
175
- */
176
- const chains = new Map();
177
- export function withHostMutex(name, fn, { env = process.env, waitMs = 10_000, stepMs = 100 } = {}) {
178
- const prior = chains.get(name) ?? Promise.resolve();
179
- const run = prior.catch(() => {}).then(async () => {
180
- const end = Date.now() + waitMs;
181
- for (;;) {
182
- let got;
183
- try { got = withMachine((m) => takeLock(m, name), { env }); } catch { got = { ok: false }; }
184
- if (got.ok) break;
185
- if (Date.now() > end) return { ok: false, skipped: `${name} is locked` };
186
- await sleep(stepMs);
187
- }
188
- try { return await fn(); } finally { try { withMachine((m) => m.releaseHostLock({ name }), { env }); } catch { /* gone */ } }
189
- });
190
- chains.set(name, run);
191
- run.catch(() => {}).finally(() => { if (chains.get(name) === run) chains.delete(name); });
192
- return run;
193
- }
194
-
195
- export const CONNECTOR_LAUNCH_ENV = 'STARCI_CONNECTOR_LAUNCH';
196
-
197
- /* ------------------------------------------------------------ connectors rows (machine.sqlite connectors) */
198
-
199
- const recordOf = (row) => (row ? { ...(row.config ?? {}), pid: row.pid ?? row.config?.pid ?? null, port: row.port ?? row.config?.port ?? null,
200
- publicUrl: row.public_url ?? null, state: row.state ?? null, cursor: row.cursor ?? null, updatedAt: row.updated_at } : null);
201
- /**
202
- * One connector's record, or null: its config_json spread, with the row's pid, port, publicUrl, state, cursor and
203
- * updatedAt. `name`: gateway | tunnel | telegram-bridge | telegram-route | supervisor-channel:<id>.
204
- */
205
- export const connectorState = (name, env = process.env) => readMachine((m) => recordOf(m.connectorOf(name)), null, { env });
206
- /** Every connector row whose name starts with `prefix`, as connectorState records with their `name`. */
207
- export const connectorStates = (prefix, env = process.env) => readMachine((m) => m.db.prepare('SELECT name FROM connectors WHERE substr(name,1,length(?))=? ORDER BY name')
208
- .all(prefix, prefix).map((r) => ({ name: r.name, ...recordOf(m.connectorOf(r.name)) })), [], { env });
209
- /**
210
- * Write one connector row: only the fields given change (`config` and `cursor` replace their column whole). Returns
211
- * true. No secret ever goes here (the token lives in .starcistacks / the secrets file).
212
- */
213
- export const writeConnectorState = (name, { kind, state, pid, port, publicUrl, config, cursor } = {}, env = process.env) => withMachine((m) => m.transaction(() => {
214
- const current = recordOf(m.connectorOf(name));
215
- if (connectorChildUnresolved(current) && config !== undefined
216
- && (config.childLaunchNonce !== current.childLaunchNonce || canonicalJSON(config.childIdentity ?? null) !== canonicalJSON(current.childIdentity ?? null)
217
- || canonicalJSON(config.childCapture ?? null) !== canonicalJSON(current.childCapture ?? null)
218
- || closedProcess(current.stopReceipt?.manager, current.processIdentity)
219
- && canonicalJSON(config.stopReceipt?.manager ?? null) !== canonicalJSON(current.stopReceipt.manager)))
220
- throw Error('connector-child-custody-unreconciled');
221
- const row = { name, updated_at: m.now(), kind, state, pid, port, public_url: publicUrl, config_json: config, cursor_json: cursor };
222
- m.upsert('connectors', Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined)), ['name']);
223
- return true;
224
- }), { env });
225
-
226
-
227
- const sameIdentity = (a, b) => Boolean(a && b) && canonicalJSON(a) === canonicalJSON(b);
228
- const sameConnectorOwner = (a, b) => Boolean(a && b) && a.pid === b.pid && a.source === b.source && sameIdentity(a.processIdentity, b.processIdentity);
229
- const closedProcess = (receipt, identity) => receipt?.schema === OWNED_PROCESS_SCHEMA && receipt.ok === true
230
- && ['gone', 'stopped'].includes(receipt.outcome) && receipt.proof === 'process-handle-signaled'
231
- && receipt.pid === identity?.pid && sameIdentity(receipt.identity, identity);
232
- const closedChild = (receipt, record) => Boolean(receipt && record.childLaunchNonce && receipt.launchNonce === record.childLaunchNonce)
233
- && (closedProcess(receipt, record.childIdentity) || receipt.ok === true && receipt.proof === 'owned-child-process-exit'
234
- && receipt.pid === record.childIdentity?.pid || receipt.ok === true && receipt.proof === 'owned-child-spawn-failed' && receipt.pid == null);
235
-
236
- /** Original child custody remains owed even when its manager or lock no longer lives. */
237
- export const connectorChildUnresolved = (record) => Boolean(record && (record.childLaunchNonce || record.childPid || record.childIdentity))
238
- && !closedChild(record.childClosure, record) && !closedChild(record.stopReceipt?.child, record);
239
- export const connectorManagerBlocked = (record) => connectorChildUnresolved(record)
240
- && (closedProcess(record.stopReceipt?.manager, record.processIdentity) || !recordAlive(record));
241
-
242
- const retainConnectorClosure = (name, expected, receipt, env) => withMachine((m) => m.transaction(() => {
243
- const row = m.connectorOf(name), current = recordOf(row);
244
- if (!sameConnectorOwner(current, expected) || canonicalJSON(current) !== canonicalJSON(expected)) return false;
245
- m.upsert('connectors', { name, updated_at: m.now(), config_json: { ...(row.config ?? {}), stopReceipt: receipt } }, ['name']);
246
- return true;
247
- }), { env });
248
-
249
- /** Close only recorded process objects; unknown effects retain the original row and a concurrent new owner is never overwritten. */
250
- export function stopConnector(name, { source, child = false, env = process.env, read = connectorState, stop = stopOwnedProcess,
251
- retain = (expected, receipt) => retainConnectorClosure(name, expected, receipt, env),
252
- commit = (expected, receipt) => withMachine((m) => m.transaction(() => {
253
- const row = m.connectorOf(name), current = recordOf(row);
254
- if (!sameConnectorOwner(current, expected) || canonicalJSON(current) !== canonicalJSON(expected)) return false;
255
- const stoppedAt = new Date(m.now()).toISOString();
256
- m.upsert('connectors', { name, state: 'stopped', pid: null, public_url: null, updated_at: m.now(),
257
- config_json: { ...(row.config ?? {}), pid: null, childPid: null, connected: false, stoppedAt, stopReceipt: receipt } }, ['name']);
258
- return true;
259
- }), { env }) } = {}) {
260
- let original;
261
- try { original = read(name, env); } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-state-unreadable', error: String(error?.message ?? error) }; }
262
- if (!original) return { ok: false, effectState: 'unknown', reason: 'connector-custody-missing' };
263
- if (original.source !== source) return { ok: false, effectState: 'none', reason: 'connector-source-conflict', custody: original };
264
- const prior = original.stopReceipt;
265
- if (original.state === 'stopped' && closedProcess(prior?.manager, original.processIdentity)
266
- && (!child || closedChild(prior?.child, original)))
267
- return { ok: true, already: true, effectState: 'completed', stopped: original.processIdentity.pid, receipt: prior };
268
- const identity = original.processIdentity;
269
- if (!identity || identity.pid !== original.pid) return { ok: false, effectState: 'none', reason: 'connector-process-custody-required', custody: original };
270
- let manager;
271
- if (closedProcess(prior?.manager, identity)) manager = prior.manager;
272
- else { try { manager = stop(identity); } catch (error) { manager = { ok: false, error: String(error?.message ?? error) }; } }
273
- if (!closedProcess(manager, identity)) return { ok: false, effectState: manager?.outcome === 'refused' ? 'none' : 'unknown',
274
- reason: 'connector-manager-closure-unverified', custody: original, receipt: { manager } };
275
- let current;
276
- try { current = read(name, env); } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-state-unreadable', custody: original, receipt: { manager }, error: String(error?.message ?? error) }; }
277
- if (!sameConnectorOwner(current, original)) return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt: { manager } };
278
- if (child) {
279
- try {
280
- if (retain(current, { manager, child: current.stopReceipt?.child ?? null }) !== true)
281
- return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt: { manager } };
282
- current = read(name, env);
283
- if (!sameConnectorOwner(current, original)) return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt: { manager } };
284
- } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt: { manager }, error: String(error?.message ?? error) }; }
285
- }
286
- let childReceipt = null;
287
- if (child) {
288
- if (closedChild(current.stopReceipt?.child, current)) childReceipt = current.stopReceipt.child;
289
- else if (!current.childPid) {
290
- if (!closedChild(current.childClosure, current)) return { ok: false, effectState: 'unknown', reason: 'connector-child-custody-unverified', custody: current, receipt: { manager } };
291
- childReceipt = current.childClosure;
292
- } else {
293
- if (!current.childIdentity || current.childIdentity.pid !== current.childPid)
294
- return { ok: false, effectState: 'unknown', reason: 'connector-child-custody-unverified', custody: current, receipt: { manager } };
295
- try { childReceipt = { ...stop(current.childIdentity), launchNonce: current.childLaunchNonce }; } catch (error) { childReceipt = { ok: false, error: String(error?.message ?? error) }; }
296
- if (!closedProcess(childReceipt, current.childIdentity)) return { ok: false, effectState: 'unknown',
297
- reason: 'connector-child-closure-unverified', custody: current, receipt: { manager, child: childReceipt } };
298
- }
299
- }
300
- const receipt = { manager, child: childReceipt };
301
- try {
302
- if (child) {
303
- if (retain(current, receipt) !== true) return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt };
304
- current = read(name, env);
305
- if (!sameConnectorOwner(current, original) || !closedChild(current?.stopReceipt?.child, current))
306
- return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt };
307
- }
308
- if (commit(current, receipt) !== true) return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt };
309
- } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt, error: String(error?.message ?? error) }; }
310
- return { ok: true, effectState: 'completed', stopped: original.pid, receipt };
311
- }
312
-
313
- /** One connector log line in machine_logs (actor connector, kind `<source>.<kind>`). Never throws. */
314
- export const connectorLog = (source, msg, { env = process.env, level = 'info', kind = 'log', data = null } = {}) =>
315
- machineLog({ actor: 'connector', kind: `${source}.${kind}`, msg: String(msg), level, data }, { env });
316
-
317
- /** Launch `node <script> ...args` detached from this process, output discarded. */
318
- export const spawnDetached = (script, args = [], { env = process.env } = {}) => {
319
- const child = spawnNode([script, ...args], { detached: true, stdio: 'ignore', cwd: skillRoot, env });
320
- child.unref();
321
- return child.pid ?? null;
322
- };
323
-
324
-
325
-
326
-
327
- /**
328
- * The repositories whose ledgers the connectors read. `connectors.repos` when the owner listed any
329
- * (relative entries resolve against the source root); otherwise the source root itself plus the Work
330
- * owner of every .workspaces/projects/<p>/work.json binding — each kept only when it holds a ledger.
331
- */
332
- export function askRepos(connectors, { env = process.env, extra = [] } = {}) {
333
- const source = starciSourceRoot(env);
334
- const listed = (connectors?.repos ?? []).map((repo) => path.resolve(source, repo));
335
- let candidates = listed;
336
- if (!listed.length) {
337
- candidates = [source];
338
- const projects = path.join(source, '.workspaces', 'projects');
339
- let entries = [];
340
- try { entries = fs.readdirSync(projects, { withFileTypes: true }); } catch { /* no bindings */ }
341
- for (const entry of entries) {
342
- if (!entry.isDirectory()) continue;
343
- const doc = readJsonFile(path.join(projects, entry.name, 'work.json'));
344
- const rel = doc?.schema === 'starci/workspace-binding@2' ? doc?.repository?.pathFromSource : null;
345
- if (typeof rel === 'string' && rel.trim()) candidates.push(path.resolve(source, rel));
346
- }
347
- }
348
- const seen = new Set(), out = [];
349
- for (const repo of [...candidates, ...extra.map((r) => path.resolve(r))]) {
350
- const key = process.platform === 'win32' ? repo.toLowerCase() : repo;
351
- if (seen.has(key) || !hasLedger(repo)) continue;
352
- seen.add(key); out.push(repo);
353
- }
354
- return out;
355
- }
356
-
357
- /** The nonce path segment serve-ask binds (`/a-<hex>`). */
358
- export const NONCE = /^a-[0-9a-f]{8,64}$/;
359
- const nonceOf = (url) => {
360
- try { const first = new URL(url).pathname.split('/')[1] ?? ''; return NONCE.test(first) ? first : null; } catch { return null; }
361
- };
362
- const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
363
- const isLoopbackUrl = (url) => {
364
- try { const u = new URL(url); return u.protocol === 'http:' && LOOPBACK.has(u.hostname); } catch { return false; }
365
- };
366
- /** A credential ask names custody files or env variables to fill (serve-ask payload.fields). */
367
- const isCredentialAsk = (fields) => Boolean((fields?.files?.length ?? 0) + (fields?.vars?.length ?? 0));
368
-
369
- const parse = parseJson;
370
-
371
- /**
372
- * Every ask whose serve-ask form is still open in one ledger, newest serving per dispatch:
373
- * not answered, not expired or superseded after it was served, and inside its ttl.
374
- */
375
- export function servingAsks(db, { now = Date.now() } = {}) {
376
- const rows = db.prepare(`SELECT seq, workflow_id, payload_json, created_at FROM events WHERE kind='ask-serving' ORDER BY seq DESC`).all();
377
- const seen = new Set(), out = [];
378
- const closedAfter = db.prepare(`SELECT 1 FROM events WHERE workflow_id=? AND json_extract(payload_json,'$.dispatchId')=?
379
- AND (kind='ask-answered' OR (kind IN ('ask-serving-expired','ask-superseded') AND seq>?)) LIMIT 1`);
380
- for (const row of rows) {
381
- const payload = parse(row.payload_json, {}) ?? {};
382
- const dispatchId = payload.dispatchId;
383
- if (!dispatchId || seen.has(`${row.workflow_id}\u0000${dispatchId}`)) continue;
384
- seen.add(`${row.workflow_id}\u0000${dispatchId}`);
385
- if (closedAfter.get(row.workflow_id, dispatchId, row.seq)) continue;
386
- const ask = servingRecord(row, payload, now);
387
- if (ask) out.push(ask);
388
- }
389
- return out;
390
- }
391
-
392
- // One ask-serving row as an open form, or null: past its ttl, not a loopback nonce URL, or its
393
- // recorded serve-ask process is gone (the form stops being served the moment its process exits).
394
- function servingRecord(row, payload, now) {
395
- if (Number.isFinite(payload.ttlMs) && row.created_at + payload.ttlMs <= now) return null;
396
- const nonce = nonceOf(payload.url);
397
- if (!nonce || !isLoopbackUrl(payload.url)) return null;
398
- if (Number.isInteger(payload.pid) && !pidAlive(payload.pid)) return null;
399
- return {
400
- seq: row.seq, workflowId: row.workflow_id, dispatchId: payload.dispatchId, url: payload.url, nonce, pid: payload.pid ?? null,
401
- fields: payload.fields ?? { files: [], vars: [] }, credential: isCredentialAsk(payload.fields), onDemand: payload.onDemand === true,
402
- createdAt: row.created_at, expiresAt: Number.isFinite(payload.ttlMs) ? row.created_at + payload.ttlMs : null,
403
- };
404
- }
405
-
406
- /**
407
- * One filed ask as the owner-facing connectors see it (read-only on `db`): {workflowId, dispatchId,
408
- * opId, title, question, closed, serving}, or null when no ask report was filed for it. `closed` is
409
- * 'answered', 'superseded' (retired, and not re-parked since: a later ask-notified or ask-serving
410
- * reopens it), else null. `serving` is the live form (servingAsks' rules) or null: an open ask
411
- * with no live form is healthy, its link is generated on demand from Telegram.
412
- */
413
- export function askState(db, workflowId, dispatchId, { now = Date.now() } = {}) {
414
- const report = db.prepare("SELECT a.op_id,r.job_id,r.report_json FROM reports r JOIN op_attempts a ON a.attempt_id=r.attempt_id WHERE r.workflow_id=? AND r.dispatch_id=? AND r.outcome='ask' ORDER BY r.report_id DESC LIMIT 1").get(workflowId, dispatchId);
415
- if (!report) return null;
416
- const last = (kinds) => db.prepare(`SELECT seq, payload_json, created_at FROM events WHERE workflow_id=? AND kind IN (${kinds.map(() => '?').join(',')})
417
- AND json_extract(payload_json,'$.dispatchId')=? ORDER BY seq DESC LIMIT 1`).get(workflowId, ...kinds, dispatchId) ?? null;
418
- const answered = last(['ask-answered']), superseded = last(['ask-superseded']), reopened = last(['ask-serving', 'ask-notified']);
419
- const closed = answered ? 'answered' : superseded && !(reopened && reopened.seq > superseded.seq) ? 'superseded' : null;
420
- let serving = null;
421
- const row = last(['ask-serving']);
422
- if (!closed && row) {
423
- const ended = last(['ask-serving-expired', 'ask-superseded']);
424
- if (!(ended && ended.seq > row.seq)) serving = servingRecord({ ...row, workflow_id: workflowId }, parse(row.payload_json, {}) ?? {}, now);
425
- }
426
- const rj = parse(report.report_json, {}) ?? {};
427
- return {
428
- // title: the workflow's display name (starci kernel rename / define-goal), else its goal slug; jobName: the asking
429
- // op job's `<op label> · <what> · <workflow name>` (scripts/lib/display-names.mjs).
430
- workflowId, dispatchId, opId: report.op_id ?? null, title: workflowNameOf(db, workflowId), jobName: askJobName(db, workflowId, report),
431
- question: rj.question ?? { text: rj.summary ?? '', options: [] }, closed, serving,
432
- };
433
- }
434
-
435
- /** The display name of the op job that filed an ask report, or null. */
436
- const askJobName = (db, workflowId, report) => {
437
- try {
438
- const job = db.prepare("SELECT * FROM jobs WHERE workflow_id=? AND job_id=? AND kind<>'kernel' LIMIT 1")
439
- .get(workflowId, report.job_id);
440
- return job ? jobDisplayNameOf(db, job) : null;
441
- } catch { return null; }
442
- };
443
-
444
- /** Every open ask of one ledger (askState with closed null), newest report first, in live workflows only. */
445
- export function openAskList(db, { now = Date.now() } = {}) {
446
- const rows = db.prepare(`SELECT r.workflow_id, r.dispatch_id FROM reports r JOIN workflows w ON w.workflow_id=r.workflow_id
447
- WHERE r.outcome='ask' AND w.archived_at IS NULL AND (w.phase IS NULL OR w.phase<>'finished') ORDER BY r.report_id DESC`).all();
448
- const seen = new Set(), out = [];
449
- for (const row of rows) {
450
- const id = `${row.workflow_id}\u0000${row.dispatch_id}`;
451
- if (seen.has(id)) continue;
452
- seen.add(id);
453
- const state = askState(db, row.workflow_id, row.dispatch_id, { now });
454
- if (state && !state.closed) out.push(state);
455
- }
456
- return out;
457
- }
458
-
459
- /** The repos the Telegram ask notices name (notifications kind 'ask'), so the gateway can route their forms. */
460
- export const notifiedRepos = (env = process.env) => readMachine((m) => [...new Set(m.db.prepare("SELECT json_extract(ref,'$.repo') repo FROM notifications WHERE kind='ask' AND json_valid(ref)").all()
461
- .map((r) => r.repo).filter((r) => typeof r === 'string' && r))], [], { env });
462
-
463
- /** Open one repo's ledger read-only for `fn`, always closing it; a missing or unreadable ledger yields `fallback`. */
464
- export function withLedgerRead(repo, fn, fallback = null) {
465
- let handle = null;
466
- try { handle = inspectLedger({ file: ledgerFileFor(repo) }); } catch { return fallback; }
467
- try { return fn(handle.db); } finally { try { handle.close(); } catch { /* closed */ } }
468
- }
469
-
470
- /** Open asks across repos, each tagged with its repo. */
471
- export const servingAsksAcross = (repos, opts = {}) =>
472
- repos.flatMap((repo) => withLedgerRead(repo, (db) => servingAsks(db, opts).map((ask) => ({ ...ask, repo })), []));
473
-
474
- /** The owner config, or null when it cannot be read (a connector never dies on a broken file it only observes). */
475
- export const ownerConfig = () => { try { return loadConfig(); } catch { return null; } };
476
-
477
- export const argsOf = (argv) => {
478
- const out = { _: [] };
479
- for (let i = 0; i < argv.length; i++) {
480
- const k = argv[i];
481
- if (!k.startsWith('--')) { out._.push(k); continue; }
482
- const key = k.slice(2), next = argv[i + 1];
483
- let value = true;
484
- if (next !== undefined && !next.startsWith('--')) { value = next; i++; }
485
- out[key] = key in out ? [].concat(out[key], value) : value;
486
- }
487
- return out;
488
- };
@@ -1,19 +0,0 @@
1
- // clip.mjs — shortening text for a message, a log line or a finding.
2
-
3
- /** `text` cut to at most `n` characters; a cut text ends in an ellipsis. */
4
- export const clip = (text, n) => { const s = String(text ?? ''); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
5
-
6
- /** `text` squashed to one line: whitespace runs collapsed, ends trimmed. */
7
- export const squash = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
8
-
9
- /** `text` on one line (whitespace runs collapsed, ends trimmed), cut to at most `n` characters. */
10
- export const clipLine = (text, n) => clip(squash(text), n);
11
-
12
- /** `text` on one line (whitespace runs collapsed, ends trimmed), cut to at most `n` characters — a plain cut, no ellipsis. */
13
- export const oneLine = (text, n = 380) => squash(text).slice(0, n);
14
-
15
- /** The last `n` lines of `text` (ends trimmed) joined with `join`, cut to at most `max` characters. */
16
- export const tailLines = (text, n = 30, { join = '\n', max = Infinity } = {}) => {
17
- const tail = String(text ?? '').trim().split(/\r?\n/).slice(-n).join(join);
18
- return Number.isFinite(max) ? tail.slice(0, max) : tail;
19
- };