@retinue/agentkit 0.2.0 → 0.3.1

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 (209) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/audio/openai.d.ts +36 -0
  3. package/dist/adapters/audio/openai.js +136 -0
  4. package/dist/adapters/bullmq/lock.d.ts +26 -2
  5. package/dist/adapters/memory/connections.d.ts +10 -0
  6. package/dist/adapters/memory/connections.js +87 -0
  7. package/dist/adapters/memory/graph.d.ts +17 -0
  8. package/dist/adapters/memory/graph.js +267 -0
  9. package/dist/adapters/memory/index.d.ts +3 -0
  10. package/dist/adapters/memory/index.js +3 -0
  11. package/dist/adapters/memory/rate-limit.d.ts +14 -0
  12. package/dist/adapters/memory/rate-limit.js +28 -0
  13. package/dist/adapters/memory/runtime.js +0 -0
  14. package/dist/adapters/postgres/connections.d.ts +15 -0
  15. package/dist/adapters/postgres/connections.js +134 -0
  16. package/dist/adapters/postgres/graph.d.ts +24 -0
  17. package/dist/adapters/postgres/graph.js +325 -0
  18. package/dist/adapters/postgres/index.d.ts +2 -0
  19. package/dist/adapters/postgres/index.js +2 -0
  20. package/dist/adapters/postgres/migrations.js +198 -0
  21. package/dist/adapters/postgres/run-store.js +26 -2
  22. package/dist/adapters/postgres/schema.d.ts +61 -2
  23. package/dist/adapters/postgres/schema.js +95 -10
  24. package/dist/adapters/redis/index.d.ts +1 -0
  25. package/dist/adapters/redis/index.js +1 -0
  26. package/dist/adapters/redis/rate-limit.d.ts +46 -0
  27. package/dist/adapters/redis/rate-limit.js +55 -0
  28. package/dist/adapters/supabase/index.d.ts +15 -0
  29. package/dist/adapters/supabase/index.js +15 -0
  30. package/dist/adapters/supabase/rls.js +33 -0
  31. package/dist/agents/agent.d.ts +78 -1
  32. package/dist/agents/agent.js +118 -5
  33. package/dist/agents/define.js +24 -4
  34. package/dist/agents/engine.d.ts +20 -0
  35. package/dist/agents/engine.js +177 -6
  36. package/dist/audio/index.d.ts +151 -0
  37. package/dist/audio/index.js +183 -0
  38. package/dist/connections/cipher.d.ts +103 -0
  39. package/dist/connections/cipher.js +141 -0
  40. package/dist/connections/index.d.ts +156 -0
  41. package/dist/connections/index.js +72 -0
  42. package/dist/connections/oauth/client.d.ts +84 -0
  43. package/dist/connections/oauth/client.js +141 -0
  44. package/dist/connections/oauth/index.d.ts +166 -0
  45. package/dist/connections/oauth/index.js +226 -0
  46. package/dist/connections/oauth/service.d.ts +90 -0
  47. package/dist/connections/oauth/service.js +132 -0
  48. package/dist/connections/pause.d.ts +68 -0
  49. package/dist/connections/pause.js +97 -0
  50. package/dist/connections/resolver.d.ts +59 -0
  51. package/dist/connections/resolver.js +138 -0
  52. package/dist/connections/resume.d.ts +64 -0
  53. package/dist/connections/resume.js +99 -0
  54. package/dist/connections/vault-cipher.d.ts +95 -0
  55. package/dist/connections/vault-cipher.js +206 -0
  56. package/dist/core/content-parts.d.ts +18 -2
  57. package/dist/core/content-parts.js +1 -0
  58. package/dist/core/context.d.ts +25 -0
  59. package/dist/core/errors.d.ts +1 -1
  60. package/dist/core/errors.js +15 -0
  61. package/dist/core/events.d.ts +44 -2
  62. package/dist/core/events.js +31 -2
  63. package/dist/core/validation.js +19 -0
  64. package/dist/entries/connections.d.ts +10 -0
  65. package/dist/entries/connections.js +10 -0
  66. package/dist/entries/knowledge.d.ts +8 -0
  67. package/dist/entries/knowledge.js +8 -0
  68. package/dist/entries/mcp-server.d.ts +9 -0
  69. package/dist/entries/mcp-server.js +9 -0
  70. package/dist/entries/runtime.d.ts +10 -0
  71. package/dist/entries/runtime.js +8 -0
  72. package/dist/entries/testing.d.ts +28 -0
  73. package/dist/entries/testing.js +28 -0
  74. package/dist/graphql/resolvers.d.ts +3 -1
  75. package/dist/graphql/resolvers.js +14 -0
  76. package/dist/knowledge/communities.d.ts +166 -0
  77. package/dist/knowledge/communities.js +377 -0
  78. package/dist/knowledge/graph-global.d.ts +153 -0
  79. package/dist/knowledge/graph-global.js +246 -0
  80. package/dist/knowledge/graph-retrieval.d.ts +150 -0
  81. package/dist/knowledge/graph-retrieval.js +303 -0
  82. package/dist/knowledge/graph.d.ts +223 -0
  83. package/dist/knowledge/graph.js +369 -0
  84. package/dist/knowledge/index.d.ts +24 -0
  85. package/dist/knowledge/index.js +36 -1
  86. package/dist/knowledge/retrieval.d.ts +26 -1
  87. package/dist/knowledge/retrieval.js +77 -1
  88. package/dist/mcp-server/index.d.ts +168 -0
  89. package/dist/mcp-server/index.js +175 -0
  90. package/dist/models/extraction.d.ts +73 -0
  91. package/dist/models/extraction.js +118 -0
  92. package/dist/models/index.d.ts +40 -1
  93. package/dist/models/index.js +22 -1
  94. package/dist/models/pricing.d.ts +2 -0
  95. package/dist/models/pricing.js +17 -1
  96. package/dist/models/provider-factory.js +9 -7
  97. package/dist/models/streaming.d.ts +89 -0
  98. package/dist/models/streaming.js +179 -2
  99. package/dist/persistence/index.d.ts +230 -0
  100. package/dist/persistence/index.js +2 -0
  101. package/dist/runtime/index.d.ts +1 -1
  102. package/dist/runtime/index.js +12 -0
  103. package/dist/runtime/worker.d.ts +36 -1
  104. package/dist/runtime/worker.js +37 -4
  105. package/dist/security/checklist.js +46 -0
  106. package/dist/server/bin.d.ts +72 -0
  107. package/dist/server/bin.js +304 -0
  108. package/dist/server/boot.d.ts +20 -2
  109. package/dist/server/boot.js +29 -3
  110. package/dist/server/cli-worker.js +6 -3
  111. package/dist/server/cli.d.ts +13 -0
  112. package/dist/server/cli.js +10 -5
  113. package/dist/server/config.d.ts +11 -1
  114. package/dist/server/config.js +18 -0
  115. package/dist/server/doctor.d.ts +85 -0
  116. package/dist/server/doctor.js +280 -0
  117. package/dist/server/pool.d.ts +47 -0
  118. package/dist/server/pool.js +73 -0
  119. package/dist/skills/context.d.ts +61 -0
  120. package/dist/skills/context.js +107 -0
  121. package/dist/skills/index.d.ts +1 -0
  122. package/dist/skills/index.js +1 -0
  123. package/dist/telemetry/spans.js +5 -0
  124. package/dist/testing/conformance/artifact-exports.d.ts +20 -0
  125. package/dist/testing/conformance/artifact-exports.js +172 -0
  126. package/dist/testing/conformance/artifacts.d.ts +11 -0
  127. package/dist/testing/conformance/artifacts.js +316 -0
  128. package/dist/testing/conformance/audio.d.ts +38 -0
  129. package/dist/testing/conformance/audio.js +145 -0
  130. package/dist/testing/conformance/capability.d.ts +23 -0
  131. package/dist/testing/conformance/capability.js +27 -0
  132. package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
  133. package/dist/testing/conformance/checkpoint-store.js +72 -0
  134. package/dist/testing/conformance/connections.d.ts +14 -0
  135. package/dist/testing/conformance/connections.js +171 -0
  136. package/dist/testing/conformance/conversation-store.d.ts +10 -0
  137. package/dist/testing/conformance/conversation-store.js +60 -0
  138. package/dist/testing/conformance/evaluation.d.ts +10 -0
  139. package/dist/testing/conformance/evaluation.js +295 -0
  140. package/dist/testing/conformance/files.d.ts +19 -0
  141. package/dist/testing/conformance/files.js +454 -0
  142. package/dist/testing/conformance/flows.d.ts +16 -0
  143. package/dist/testing/conformance/flows.js +193 -0
  144. package/dist/testing/conformance/graph.d.ts +22 -0
  145. package/dist/testing/conformance/graph.js +500 -0
  146. package/dist/testing/conformance/hitl.d.ts +25 -0
  147. package/dist/testing/conformance/hitl.js +523 -0
  148. package/dist/testing/conformance/index.d.ts +163 -0
  149. package/dist/testing/conformance/index.js +321 -0
  150. package/dist/testing/conformance/invariants.d.ts +23 -0
  151. package/dist/testing/conformance/invariants.js +80 -0
  152. package/dist/testing/conformance/knowledge.d.ts +41 -0
  153. package/dist/testing/conformance/knowledge.js +592 -0
  154. package/dist/testing/conformance/parents.d.ts +50 -0
  155. package/dist/testing/conformance/parents.js +39 -0
  156. package/dist/testing/conformance/rate-limit.d.ts +25 -0
  157. package/dist/testing/conformance/rate-limit.js +71 -0
  158. package/dist/testing/conformance/records.d.ts +36 -0
  159. package/dist/testing/conformance/records.js +400 -0
  160. package/dist/testing/conformance/rollups.d.ts +30 -0
  161. package/dist/testing/conformance/rollups.js +425 -0
  162. package/dist/testing/conformance/run-coordinator.d.ts +27 -0
  163. package/dist/testing/conformance/run-coordinator.js +150 -0
  164. package/dist/testing/conformance/run-event-log.d.ts +9 -0
  165. package/dist/testing/conformance/run-event-log.js +138 -0
  166. package/dist/testing/conformance/run-store.d.ts +12 -0
  167. package/dist/testing/conformance/run-store.js +294 -0
  168. package/dist/testing/conformance/session-state.d.ts +38 -0
  169. package/dist/testing/conformance/session-state.js +198 -0
  170. package/dist/testing/conformance/usage-limits.d.ts +10 -0
  171. package/dist/testing/conformance/usage-limits.js +309 -0
  172. package/dist/testing/conformance.d.ts +8 -0
  173. package/dist/testing/conformance.js +8 -0
  174. package/dist/testing/memory-backend.d.ts +39 -0
  175. package/dist/testing/memory-backend.js +41 -0
  176. package/dist/testing/pglite.d.ts +55 -0
  177. package/dist/testing/pglite.js +78 -0
  178. package/dist/testing/stub-model.d.ts +58 -0
  179. package/dist/testing/stub-model.js +71 -0
  180. package/dist/testing/supabase-storage-double.d.ts +35 -0
  181. package/dist/testing/supabase-storage-double.js +101 -0
  182. package/dist/toolkit/http.d.ts +27 -0
  183. package/dist/toolkit/http.js +48 -0
  184. package/dist/toolkit/index.d.ts +27 -1
  185. package/dist/toolkit/index.js +24 -1
  186. package/dist/toolkit/mime.d.ts +97 -0
  187. package/dist/toolkit/mime.js +233 -0
  188. package/dist/toolkit/ssrf.d.ts +139 -0
  189. package/dist/toolkit/ssrf.js +349 -0
  190. package/dist/toolkit/vendor.d.ts +91 -0
  191. package/dist/toolkit/vendor.js +153 -0
  192. package/dist/tools/credentials.d.ts +265 -6
  193. package/dist/tools/credentials.js +318 -3
  194. package/dist/tools/define.d.ts +14 -0
  195. package/dist/tools/define.js +3 -0
  196. package/dist/tools/index.d.ts +21 -0
  197. package/dist/tools/index.js +7 -0
  198. package/dist/tools/library/audio.d.ts +63 -0
  199. package/dist/tools/library/audio.js +148 -0
  200. package/dist/tools/library/index.d.ts +8 -0
  201. package/dist/tools/library/index.js +7 -0
  202. package/dist/tools/registry.d.ts +33 -0
  203. package/dist/tools/registry.js +72 -2
  204. package/dist/usage/index.d.ts +21 -0
  205. package/dist/usage/index.js +1 -0
  206. package/dist/usage/rate-limit.d.ts +122 -0
  207. package/dist/usage/rate-limit.js +131 -0
  208. package/dist/usage/recorder.js +3 -0
  209. package/package.json +29 -2
@@ -5,16 +5,37 @@
5
5
  * substance: configuration is validated *before* anything connects, so a misconfigured deployment
6
6
  * fails with a message about the variable rather than a connection error that names nothing useful.
7
7
  */
8
- import { provisionSchema } from "../entries/adapters-postgres.js";
8
+ import { provisionSchema, } from "../entries/adapters-postgres.js";
9
+ import { createTransactionScope } from "../adapters/postgres/transaction.js";
9
10
  import { loadConfig } from "./config.js";
10
11
  export const boot = async (options) => {
11
12
  const log = options.log ?? ((entry) => console.log(JSON.stringify(entry)));
12
13
  // Configuration first, and it throws. A half-configured process that boots is worse than one that
13
14
  // refuses: it passes its own health check and fails on the first real request.
14
15
  const config = loadConfig(options.env);
15
- const { sql } = await options.connect(config);
16
+ const { sql: base, open } = await options.connect(config);
17
+ /**
18
+ * The transaction scope, when the caller can open a connection — found by #254.
19
+ *
20
+ * `runApiHost` never built one, so `app.deps({ config, sql })` was called without a runner and the reference
21
+ * app's coordinator refused: *"this process has no TransactionRunner, so the conversation run coordinator
22
+ * cannot be used. The API host supplies one"*. It did not. `sendMessage` — the mutation that starts every run
23
+ * — therefore failed with an internal error in the shipped API host and in `npm run api`, which is the
24
+ * documented way to run the reference app.
25
+ */
26
+ const scope = open === undefined ? undefined : createTransactionScope(open);
27
+ const sql = scope === undefined ? base : scope.scoped(base);
16
28
  const provisioned = await provisionSchema(sql, {
17
29
  mode: config.schemaMode,
30
+ /**
31
+ * Passed so `auto` mode serialises — #266.
32
+ *
33
+ * Several workers booting against a fresh database is the ordinary Kubernetes init pattern, and without
34
+ * this one of them crashes on Postgres's own type catalogue with an error naming nothing actionable.
35
+ * `undefined` for a process whose adapter cannot hold a connection, which is correct: those are
36
+ * single-process and have nothing to race with.
37
+ */
38
+ ...(open === undefined ? {} : { open }),
18
39
  // Routed through the structured log rather than printed, so `plan` mode's diff is machine-readable
19
40
  // in the same stream as everything else.
20
41
  log: (message) => log({ event: "schema", mode: config.schemaMode, message }),
@@ -31,6 +52,11 @@ export const boot = async (options) => {
31
52
  // The adapters actually selected, so a support question does not start with "which database?".
32
53
  adapters: { store: "postgres", queue: "bullmq", lock: "redis" },
33
54
  });
34
- return { config, sql, schema: { mode: config.schemaMode, applied: provisioned.applied } };
55
+ return {
56
+ config,
57
+ sql,
58
+ ...(scope === undefined ? {} : { runner: scope.runner }),
59
+ schema: { mode: config.schemaMode, applied: provisioned.applied },
60
+ };
35
61
  };
36
62
  //# sourceMappingURL=boot.js.map
@@ -25,9 +25,12 @@ export const runWorker = async (env = process.env) => {
25
25
  const { config, sql } = await boot({
26
26
  env,
27
27
  connect: async (loaded) => {
28
- const { Pool } = await import("pg");
29
- const { createPgExecutor } = await import("../entries/adapters-postgres.js");
30
- return { sql: createPgExecutor(new Pool({ connectionString: loaded.databaseUrl })) };
28
+ const { openPostgres } = await import("./pool.js");
29
+ // Through the shared pool for the schema, which matters most here: the worker is the process whose
30
+ // writes nobody watches, so a worker in `public` while the host is in `retinue` is a split brain
31
+ // that shows up as runs that vanish rather than as an error.
32
+ const { sql } = await openPostgres(loaded);
33
+ return { sql };
31
34
  },
32
35
  });
33
36
  /**
@@ -2,6 +2,7 @@ import { type Authenticate } from "./host.js";
2
2
  import { type RetinueConfig } from "./config.js";
3
3
  import type { ResolverDeps } from "../index.js";
4
4
  import type { SqlExecutor } from "../entries/adapters-postgres.js";
5
+ import type { TransactionRunner } from "../adapters/postgres/transaction.js";
5
6
  /**
6
7
  * What a deployment's app module must default-export.
7
8
  *
@@ -10,9 +11,21 @@ import type { SqlExecutor } from "../entries/adapters-postgres.js";
10
11
  */
11
12
  export type RetinueApp = {
12
13
  readonly authenticate: Authenticate;
14
+ /**
15
+ * `runner` added by #254, and it was not optional in practice — it was missing.
16
+ *
17
+ * The reference app's `deps` has always accepted an optional `TransactionRunner`, and this host never passed
18
+ * one, so the conversation run coordinator refused with *"this process has no TransactionRunner… The API host
19
+ * supplies one"*. It did not. `sendMessage` — the mutation that starts every run — failed with an internal
20
+ * error in this command and in `npm run api`, which is the documented way to run the reference app.
21
+ *
22
+ * Still optional on the signature, because a worker genuinely does not need it and an app that ignores it
23
+ * keeps compiling.
24
+ */
13
25
  readonly deps: (input: {
14
26
  readonly config: RetinueConfig;
15
27
  readonly sql: SqlExecutor;
28
+ readonly runner?: TransactionRunner;
16
29
  }) => Promise<ResolverDeps> | ResolverDeps;
17
30
  /** Optional liveness/readiness extras beyond Postgres, Redis and the schema version. */
18
31
  readonly redis?: (config: RetinueConfig) => {
@@ -35,15 +35,20 @@ export const runApiHost = async (env = process.env) => {
35
35
  // twice: `loadConfig` is pure and `boot` validates again.
36
36
  loadConfig(env);
37
37
  const app = await loadApp(env);
38
- const { config, sql } = await boot({
38
+ const { config, sql, runner } = await boot({
39
39
  env,
40
40
  connect: async (loaded) => {
41
- const { Pool } = await import("pg");
42
- const { createPgExecutor } = await import("../entries/adapters-postgres.js");
43
- return { sql: createPgExecutor(new Pool({ connectionString: loaded.databaseUrl })) };
41
+ const { openPostgres } = await import("./pool.js");
42
+ // `open` is what lets `boot` build a transaction scope. Without it the API host had no runner and
43
+ // `sendMessage` could not claim a conversation #254.
44
+ //
45
+ // The whole config is passed, not just the URL: `databaseSchema` has to reach the pool or the host
46
+ // reads and writes `public` while migrations ran somewhere else.
47
+ const { sql, open } = await openPostgres(loaded);
48
+ return { sql, open };
44
49
  },
45
50
  });
46
- const deps = await app.deps({ config, sql });
51
+ const deps = await app.deps({ config, sql, ...(runner === undefined ? {} : { runner }) });
47
52
  const { createSchemaManager } = await import("../entries/adapters-postgres.js");
48
53
  const probes = [postgresProbe(sql), schemaProbe(createSchemaManager(sql))];
49
54
  if (app.redis)
@@ -4,6 +4,16 @@ export type RetinueConfig = {
4
4
  readonly redisUrl: string;
5
5
  /** How the schema is provisioned at boot. `off` in production, so managed migrations stay in control. */
6
6
  readonly schemaMode: SchemaMode;
7
+ /**
8
+ * The Postgres schema the platform's own tables live in, or `undefined` for the connection's default.
9
+ *
10
+ * Exists because a deployment may be sharing a database with a product that owns `public` — which is
11
+ * exactly the ShareFlow case: its adapters qualify every one of their queries as `public.`, so with this
12
+ * set to `retinue` one pool serves both, platform tables in one schema and product tables in the other.
13
+ * Without it the platform's 35 migrations land in `public` alongside the product's, and the first name
14
+ * they share is a migration that fails or, worse, one that succeeds against the wrong table.
15
+ */
16
+ readonly databaseSchema?: string;
7
17
  readonly port: number;
8
18
  readonly workerConcurrency: number;
9
19
  readonly logLevel: "debug" | "info" | "warn" | "error";
@@ -38,5 +48,5 @@ export declare const loadConfig: (env: Env) => RetinueConfig;
38
48
  * test that asserts the error names every missing variable is what found it.
39
49
  */
40
50
  export declare const REQUIRED_VARIABLES: readonly ["RETINUE_DATABASE_URL", "RETINUE_REDIS_URL"];
41
- export declare const OPTIONAL_VARIABLES: readonly ["SCHEMA_MODE", "WORKER_CONCURRENCY", "LOG_LEVEL", "PORT"];
51
+ export declare const OPTIONAL_VARIABLES: readonly ["SCHEMA_MODE", "DATABASE_SCHEMA", "WORKER_CONCURRENCY", "LOG_LEVEL", "PORT"];
42
52
  //# sourceMappingURL=config.d.ts.map
@@ -76,6 +76,20 @@ export const loadConfig = (env) => {
76
76
  if (!SCHEMA_MODES.includes(rawSchemaMode)) {
77
77
  fail(named("SCHEMA_MODE"), `must be one of ${SCHEMA_MODES.join(", ")}, got "${rawSchemaMode}"`);
78
78
  }
79
+ /**
80
+ * A schema name, validated as an identifier rather than quoted.
81
+ *
82
+ * `SET search_path` takes no parameters — it is not a value position, so there is no placeholder to bind
83
+ * and the name is concatenated into SQL. That makes this the one config value where a lax check is a SQL
84
+ * injection with the deployment's own credentials, so the pattern is deliberately narrow: what Postgres
85
+ * accepts unquoted, and nothing else. A rejected name fails boot, where it is one line to fix.
86
+ */
87
+ const rawSchema = lookup("DATABASE_SCHEMA");
88
+ const databaseSchema = rawSchema === undefined || rawSchema.trim() === "" ? undefined : rawSchema.trim();
89
+ if (databaseSchema !== undefined && !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(databaseSchema)) {
90
+ fail(named("DATABASE_SCHEMA"), `must be an unquoted Postgres identifier — letters, digits, underscore and $, not starting with a ` +
91
+ `digit — got "${databaseSchema.slice(0, 24)}"`);
92
+ }
79
93
  const positiveInt = (suffix, fallback) => {
80
94
  // `PORT` has no prefix — it is the conventional name and always has been, so it is read directly.
81
95
  const variable = suffix === "PORT" ? "PORT" : named(suffix);
@@ -103,6 +117,9 @@ export const loadConfig = (env) => {
103
117
  databaseUrl,
104
118
  redisUrl,
105
119
  schemaMode: rawSchemaMode,
120
+ // Spread, not `databaseSchema: undefined` — `exactOptionalPropertyTypes` is on, so an explicit
121
+ // undefined is a different type from an absent key.
122
+ ...(databaseSchema === undefined ? {} : { databaseSchema }),
106
123
  port,
107
124
  workerConcurrency,
108
125
  logLevel: rawLogLevel,
@@ -120,6 +137,7 @@ export const loadConfig = (env) => {
120
137
  export const REQUIRED_VARIABLES = ["RETINUE_DATABASE_URL", "RETINUE_REDIS_URL"];
121
138
  export const OPTIONAL_VARIABLES = [
122
139
  "SCHEMA_MODE",
140
+ "DATABASE_SCHEMA",
123
141
  "WORKER_CONCURRENCY",
124
142
  "LOG_LEVEL",
125
143
  "PORT",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `retinue doctor` — task #252 AC-4.
3
+ *
4
+ * The one command here that is new rather than a wrapper, and the one that pays for itself: every failure it
5
+ * names is otherwise a support conversation. A deployment that will not start currently produces one error, from
6
+ * whichever check happened to run first, and the operator fixes it and runs again to find the next.
7
+ *
8
+ * ## Every failure, not the first
9
+ *
10
+ * The whole value is in the plural. A doctor that stops at the first problem sends the user round the loop once
11
+ * per problem — which is exactly what starting the server already does, so it would add nothing. Each check is
12
+ * therefore independent and its own failure is caught, so a Postgres that will not connect does not prevent the
13
+ * Redis check from running.
14
+ *
15
+ * ## Nothing here prints a secret
16
+ *
17
+ * A diagnostic tool is exactly where a URL with a password in it gets pasted into a support ticket. So a
18
+ * connection string is reported by its *shape* — host and database, never userinfo — and a failure message is
19
+ * the driver's, scrubbed. `mcp/egress.ts` already refuses credentials in a URL for the same reason.
20
+ */
21
+ export type CheckResult = {
22
+ readonly name: string;
23
+ readonly ok: boolean;
24
+ /** What was found. Never a secret. */
25
+ readonly detail: string;
26
+ /** Present when `ok` is false: what to do about it. */
27
+ readonly remedy?: string;
28
+ /**
29
+ * A check that could not run rather than one that failed.
30
+ *
31
+ * Distinct because the two need different responses: a failed check is a broken deployment, and a skipped one
32
+ * is a deployment this tool cannot see into. Reporting a skip as a pass would be the "passes having checked
33
+ * nothing" failure; reporting it as a failure would tell an operator to fix something that is not wrong.
34
+ */
35
+ readonly skipped?: boolean;
36
+ };
37
+ export declare const describeUrl: (value: string) => string;
38
+ /** The driver's message, with anything URL-shaped reduced. Errors quote the connection string routinely. */
39
+ export declare const scrub: (message: string) => string;
40
+ export type DoctorDeps = {
41
+ readonly env?: Readonly<Record<string, string | undefined>>;
42
+ /**
43
+ * Injected so the checks are testable without a database or a Redis.
44
+ *
45
+ * Takes the whole connection setting rather than a URL, because `databaseSchema` changes the answer:
46
+ * the schema probe counts applied migrations, and reading `public` when the deployment configured
47
+ * `retinue` reports "0 of 35 applied → run migrate" about a schema that is fully migrated. The
48
+ * comment below already names that class of bug — a diagnostic sending an operator to fix the wrong
49
+ * thing — and a URL-only signature is how this one would have got in.
50
+ */
51
+ readonly connectPostgres?: (settings: {
52
+ readonly databaseUrl: string;
53
+ readonly databaseSchema?: string;
54
+ }) => Promise<{
55
+ query(text: string, params?: readonly unknown[]): Promise<unknown>;
56
+ end(): Promise<void>;
57
+ }>;
58
+ readonly connectRedis?: (url: string) => Promise<{
59
+ ping(): Promise<string>;
60
+ quit(): Promise<unknown>;
61
+ }>;
62
+ readonly schemaVersions?: (sql: {
63
+ query(text: string, params?: readonly unknown[]): Promise<unknown>;
64
+ }) => Promise<{
65
+ current: number;
66
+ target: number;
67
+ }>;
68
+ };
69
+ /** How long any one check may take before it is reported as a timeout. */
70
+ export declare const CHECK_TIMEOUT_MS = 5000;
71
+ /**
72
+ * A backstop, because **a diagnostic that hangs is worse than one that fails.**
73
+ *
74
+ * Found by running `doctor` against a refused port: `pg.Pool` has no default connect timeout and `ioredis`
75
+ * retries a refused connection for ever, so the command sat there silently instead of reporting the two
76
+ * failures it existed to report. Both drivers are now configured to fail fast, and this is the belt to that
77
+ * braces — a driver whose timeout option is wrong or renamed must not be able to hang the tool.
78
+ *
79
+ * The timer is unref'd so a completed check never holds the process open past its work.
80
+ */
81
+ export declare const withTimeout: <T>(what: string, work: Promise<T>, ms?: number) => Promise<T>;
82
+ export declare const runChecks: (deps?: DoctorDeps) => Promise<readonly CheckResult[]>;
83
+ /** Human output. Returns the exit code, so the caller does not decide what a failure means. */
84
+ export declare const report: (results: readonly CheckResult[], write?: (line: string) => void) => number;
85
+ //# sourceMappingURL=doctor.d.ts.map
@@ -0,0 +1,280 @@
1
+ /**
2
+ * `retinue doctor` — task #252 AC-4.
3
+ *
4
+ * The one command here that is new rather than a wrapper, and the one that pays for itself: every failure it
5
+ * names is otherwise a support conversation. A deployment that will not start currently produces one error, from
6
+ * whichever check happened to run first, and the operator fixes it and runs again to find the next.
7
+ *
8
+ * ## Every failure, not the first
9
+ *
10
+ * The whole value is in the plural. A doctor that stops at the first problem sends the user round the loop once
11
+ * per problem — which is exactly what starting the server already does, so it would add nothing. Each check is
12
+ * therefore independent and its own failure is caught, so a Postgres that will not connect does not prevent the
13
+ * Redis check from running.
14
+ *
15
+ * ## Nothing here prints a secret
16
+ *
17
+ * A diagnostic tool is exactly where a URL with a password in it gets pasted into a support ticket. So a
18
+ * connection string is reported by its *shape* — host and database, never userinfo — and a failure message is
19
+ * the driver's, scrubbed. `mcp/egress.ts` already refuses credentials in a URL for the same reason.
20
+ */
21
+ import { ConfigurationError, loadConfig } from "./config.js";
22
+ /**
23
+ * A connection string reduced to what is safe to print.
24
+ *
25
+ * Deliberately not a regex over the whole string: `URL` parsing means a password containing a `@` or a `/`
26
+ * cannot smuggle itself into the output. An unparseable value is reported as unparseable rather than echoed,
27
+ * because echoing it is how a secret reaches a log.
28
+ */
29
+ /**
30
+ * A connection string reduced to what is safe to print, by **whitelist**.
31
+ *
32
+ * Two earlier versions of this leaked, and both leaked the same way — by trying to *remove* the secret from a
33
+ * string whose structure was not what they assumed:
34
+ *
35
+ * 1. Parsing with `URL` and printing `hostname`/`port`/`pathname`. For
36
+ * `postgres://user:p@ss/word@db.internal:5432/app`, `URL` reads `user:p` as the userinfo and `ss` as the
37
+ * hostname, so the "safe" output was `postgres://ss/word@db.internal:5432/app` — half the password, spread
38
+ * across the host and path.
39
+ * 2. Refusing when the *authority* contained two `@`. The authority ends at the first `/`, and in that example
40
+ * the second `@` is after it. So the check passed and the leak stood.
41
+ *
42
+ * A redaction function must therefore not try to find the secret. It matches a strict, unambiguous shape and
43
+ * prints only the groups it captured; anything else is refused unprinted. An unescaped `@` or `/` in a password
44
+ * is common, and it is exactly what makes a connection string ambiguous — so ambiguity is the thing to refuse,
45
+ * not to parse harder.
46
+ */
47
+ const SAFE_URL = /^([a-z][a-z0-9+.-]*):\/\/(?:[^@/?#]*@)?([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\])(?::(\d+))?(?:\/([A-Za-z0-9._-]*))?$/;
48
+ export const describeUrl = (value) => {
49
+ const match = SAFE_URL.exec(value);
50
+ if (match === null)
51
+ return "(not printed — the value does not match an unambiguous connection-string shape)";
52
+ const [, scheme, host, port, database] = match;
53
+ return `${scheme}://${host}${port === undefined ? "" : `:${port}`}${database ? `/${database}` : ""}`;
54
+ };
55
+ /** The driver's message, with anything URL-shaped reduced. Errors quote the connection string routinely. */
56
+ export const scrub = (message) => message.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi, (m) => describeUrl(m));
57
+ const message = (error) => scrub(error instanceof Error ? error.message : String(error));
58
+ /** True when `describeUrl` refused to print a value, so nothing derived from it can be trusted either. */
59
+ const unsafe = (described) => described.startsWith("(");
60
+ /**
61
+ * What to say about a connection failure — the URL and the driver's message, or neither.
62
+ *
63
+ * A driver interpolates whatever *it* parsed, so `postgres://u:p@ss/word@host/db` fails with
64
+ * `getaddrinfo ENOTFOUND ss` — a fragment of the password, in a message no scrubber can recognise as one
65
+ * because `ss` is not URL-shaped. The rule that follows is simple and the only safe one: if the value could not
66
+ * be printed, the error derived from it is not printed either. The remedy still names the variable, which is
67
+ * what the operator needs.
68
+ */
69
+ const failureDetail = (url, error) => {
70
+ const described = describeUrl(url);
71
+ return unsafe(described)
72
+ ? `${described} The driver's message is withheld for the same reason — it can quote the value it parsed.`
73
+ : `${described}: ${message(error)}`;
74
+ };
75
+ /** How long any one check may take before it is reported as a timeout. */
76
+ export const CHECK_TIMEOUT_MS = 5_000;
77
+ /**
78
+ * A backstop, because **a diagnostic that hangs is worse than one that fails.**
79
+ *
80
+ * Found by running `doctor` against a refused port: `pg.Pool` has no default connect timeout and `ioredis`
81
+ * retries a refused connection for ever, so the command sat there silently instead of reporting the two
82
+ * failures it existed to report. Both drivers are now configured to fail fast, and this is the belt to that
83
+ * braces — a driver whose timeout option is wrong or renamed must not be able to hang the tool.
84
+ *
85
+ * The timer is unref'd so a completed check never holds the process open past its work.
86
+ */
87
+ export const withTimeout = async (what, work, ms = CHECK_TIMEOUT_MS) => {
88
+ let timer;
89
+ try {
90
+ return await Promise.race([
91
+ work,
92
+ new Promise((_, reject) => {
93
+ timer = setTimeout(() => reject(new Error(`${what} did not respond within ${ms}ms`)), ms);
94
+ timer.unref?.();
95
+ }),
96
+ ]);
97
+ }
98
+ finally {
99
+ if (timer !== undefined)
100
+ clearTimeout(timer);
101
+ }
102
+ };
103
+ export const runChecks = async (deps = {}) => {
104
+ const env = deps.env ?? process.env;
105
+ const results = [];
106
+ // 1. Configuration. Everything else needs it, so a failure here skips the rest rather than reporting
107
+ // cascading failures that are all the same problem.
108
+ let config;
109
+ try {
110
+ config = loadConfig(env);
111
+ results.push({ name: "configuration", ok: true, detail: `schema mode ${config.schemaMode}, port ${config.port}` });
112
+ }
113
+ catch (error) {
114
+ results.push({
115
+ name: "configuration",
116
+ ok: false,
117
+ detail: message(error),
118
+ remedy: error instanceof ConfigurationError
119
+ ? `Set or correct: ${error.variables.join(", ")}. See .env.example.`
120
+ : "See .env.example for the variables this deployment needs.",
121
+ });
122
+ for (const name of ["postgres", "schema", "redis"]) {
123
+ results.push({
124
+ name,
125
+ ok: true,
126
+ skipped: true,
127
+ detail: "not checked — configuration is unusable, so this would fail for the same reason",
128
+ });
129
+ }
130
+ return results;
131
+ }
132
+ // 2. The app module, which `serve` and `worker` need and `migrate` does not.
133
+ const appModule = env["RETINUE_APP_MODULE"];
134
+ if (appModule === undefined || appModule.trim() === "") {
135
+ results.push({
136
+ name: "app module",
137
+ ok: true,
138
+ skipped: true,
139
+ detail: "RETINUE_APP_MODULE is unset — `migrate` and `doctor` work without it; `serve` and `worker` do not",
140
+ remedy: "Point RETINUE_APP_MODULE at a module default-exporting { authenticate, deps } before serving.",
141
+ });
142
+ }
143
+ else {
144
+ try {
145
+ const loaded = (await import(appModule));
146
+ const app = loaded.default;
147
+ const ok = typeof app?.authenticate === "function" && typeof app?.deps === "function";
148
+ results.push({
149
+ name: "app module",
150
+ ok,
151
+ detail: ok ? `${appModule} exports { authenticate, deps }` : `${appModule} loaded but is missing exports`,
152
+ ...(ok ? {} : { remedy: "It must default-export { authenticate, deps }. `authenticate` has no default." }),
153
+ });
154
+ }
155
+ catch (error) {
156
+ results.push({
157
+ name: "app module",
158
+ ok: false,
159
+ detail: `${appModule} could not be loaded: ${message(error)}`,
160
+ remedy: "Check the path is resolvable from the working directory, and that the module builds.",
161
+ });
162
+ }
163
+ }
164
+ // 3. Postgres, and 4. the schema — one connection, two findings.
165
+ const connectPostgres = deps.connectPostgres;
166
+ if (connectPostgres === undefined) {
167
+ results.push({ name: "postgres", ok: true, skipped: true, detail: "no driver supplied" });
168
+ results.push({ name: "schema", ok: true, skipped: true, detail: "no driver supplied" });
169
+ }
170
+ else {
171
+ let sql;
172
+ /**
173
+ * Whether Postgres *answered*, which is not the same as whether a pool object exists.
174
+ *
175
+ * `new Pool()` does not connect, so `sql` is defined even when the database is unreachable — and keying the
176
+ * schema check on `sql !== undefined` reported "0 of 30 migrations applied → Run `retinue migrate`" against
177
+ * a database nobody could reach. That is a diagnostic sending an operator to fix the wrong thing, which is
178
+ * worse than reporting nothing.
179
+ */
180
+ let reachable = false;
181
+ try {
182
+ sql = await withTimeout("postgres", connectPostgres(config));
183
+ await withTimeout("postgres", sql.query("select 1"));
184
+ reachable = true;
185
+ results.push({ name: "postgres", ok: true, detail: `reachable at ${describeUrl(config.databaseUrl)}` });
186
+ }
187
+ catch (error) {
188
+ results.push({
189
+ name: "postgres",
190
+ ok: false,
191
+ detail: failureDetail(config.databaseUrl, error),
192
+ remedy: "Check the database is running and RETINUE_DATABASE_URL points at it.",
193
+ });
194
+ }
195
+ if (reachable && sql !== undefined && deps.schemaVersions !== undefined) {
196
+ try {
197
+ const { current, target } = await withTimeout("schema", deps.schemaVersions(sql));
198
+ // Ahead is reported as a failure, not a pass: a database migrated by a newer build than this one is a
199
+ // deployment about to behave unpredictably, and "current >= target" would call it healthy.
200
+ const ok = current === target;
201
+ results.push({
202
+ name: "schema",
203
+ ok,
204
+ detail: `${current} of ${target} migrations applied`,
205
+ ...(ok
206
+ ? {}
207
+ : {
208
+ remedy: current < target
209
+ ? "Run `retinue migrate`."
210
+ : "This database is ahead of this build. Deploy the matching version rather than migrating down.",
211
+ }),
212
+ });
213
+ }
214
+ catch (error) {
215
+ results.push({
216
+ name: "schema",
217
+ ok: false,
218
+ detail: message(error),
219
+ remedy: "Run `retinue migrate --status` for detail.",
220
+ });
221
+ }
222
+ }
223
+ else if (reachable) {
224
+ results.push({ name: "schema", ok: true, skipped: true, detail: "no schema reader supplied" });
225
+ }
226
+ else {
227
+ results.push({
228
+ name: "schema",
229
+ ok: true,
230
+ skipped: true,
231
+ detail: "not checked — Postgres is unreachable, so this would fail for the same reason",
232
+ });
233
+ }
234
+ if (sql !== undefined)
235
+ await sql.end().catch(() => undefined);
236
+ }
237
+ // 5. Redis.
238
+ if (deps.connectRedis === undefined) {
239
+ results.push({ name: "redis", ok: true, skipped: true, detail: "no client supplied" });
240
+ }
241
+ else {
242
+ let redis;
243
+ try {
244
+ redis = await withTimeout("redis", deps.connectRedis(config.redisUrl));
245
+ const pong = await withTimeout("redis", redis.ping());
246
+ results.push({ name: "redis", ok: true, detail: `reachable at ${describeUrl(config.redisUrl)} (${pong})` });
247
+ }
248
+ catch (error) {
249
+ results.push({
250
+ name: "redis",
251
+ ok: false,
252
+ detail: failureDetail(config.redisUrl, error),
253
+ remedy: "Check Redis is running and RETINUE_REDIS_URL points at it.",
254
+ });
255
+ }
256
+ finally {
257
+ // Always, including after a failure. Leaving a client open kept the socket handle alive and the command
258
+ // never exited — a diagnostic that reports correctly and then hangs is still a diagnostic that hangs.
259
+ if (redis !== undefined)
260
+ await redis.quit().catch(() => undefined);
261
+ }
262
+ }
263
+ return results;
264
+ };
265
+ /** Human output. Returns the exit code, so the caller does not decide what a failure means. */
266
+ export const report = (results, write = console.log) => {
267
+ const failed = results.filter((r) => !r.ok);
268
+ for (const r of results) {
269
+ const mark = !r.ok ? "✗" : r.skipped === true ? "–" : "✓";
270
+ write(`${mark} ${r.name}: ${r.detail}`);
271
+ if (r.remedy !== undefined)
272
+ write(` → ${r.remedy}`);
273
+ }
274
+ write("");
275
+ write(failed.length === 0
276
+ ? `✓ ${results.filter((r) => r.skipped !== true).length} check(s) passed`
277
+ : `✗ ${failed.length} of ${results.length} check(s) failed`);
278
+ return failed.length === 0 ? 0 : 1;
279
+ };
280
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The one place a `pg.Pool` is built for this host — REQ-041 (#190).
3
+ *
4
+ * There were four: `bin.ts` for `migrate`/`doctor`, `cli.ts` for the API host, `cli-worker.ts` for the
5
+ * worker, and the driver `doctor` is handed. Four copies of "how this deployment connects" is three
6
+ * copies too many, and the one that drifts is the one nobody exercises — the worker, whose writes are
7
+ * the ones that must land in the same schema as everything else.
8
+ *
9
+ * **What it adds over `new Pool`.** `RETINUE_DATABASE_SCHEMA` names the schema the platform's tables
10
+ * live in, and honouring it takes two things that must agree:
11
+ *
12
+ * - `pool.on("connect")` sets `search_path` on **every** connection, because `createPgExecutor` runs
13
+ * each query through `pool.query`, which takes a different connection per call. A pooled connection
14
+ * carries whatever `search_path` its last user left, so setting it once at startup means the first
15
+ * few statements land in the right schema and the rest land wherever. That failure is not loud: it
16
+ * is a table created in `public` by a migration that reported success.
17
+ * - `createPoolOpener(pool, …)` sets it again per checkout, which is what the transaction scope uses.
18
+ * Redundant on paper and deliberately kept: the opener is the path that holds `SELECT … FOR UPDATE`
19
+ * across statements, and it must not depend on a listener having fired.
20
+ *
21
+ * **`public` stays on the path** after the named schema. Two things need it: the `vector` type is
22
+ * pinned to `public` so it resolves from any schema (see the note in `migrations.ts`), and ShareFlow's
23
+ * adapters qualify all 70 of their queries as `public.`, which is what lets one pool serve a platform
24
+ * schema and a product schema at once.
25
+ */
26
+ import type { SqlExecutor } from "../adapters/postgres/sql.js";
27
+ import type { ConnectionOpener } from "../adapters/postgres/transaction.js";
28
+ export type PostgresConnection = {
29
+ readonly sql: SqlExecutor;
30
+ readonly open: ConnectionOpener;
31
+ readonly end: () => Promise<void>;
32
+ };
33
+ export type PoolSettings = {
34
+ readonly databaseUrl: string;
35
+ readonly databaseSchema?: string;
36
+ /** Left unset by the host and the worker, which is node-postgres's default of no timeout. */
37
+ readonly connectionTimeoutMillis?: number;
38
+ };
39
+ /**
40
+ * The `search_path` for a named schema, or `undefined` to leave the connection's own.
41
+ *
42
+ * Exported because it is the only string in this file that ends up in SQL, and a test that pins it is
43
+ * cheaper than reading two call sites to find out what a deployment actually gets.
44
+ */
45
+ export declare const searchPathFor: (schema: string | undefined) => string | undefined;
46
+ export declare const openPostgres: (settings: PoolSettings) => Promise<PostgresConnection>;
47
+ //# sourceMappingURL=pool.d.ts.map