@wardby/cli 0.1.0 → 0.2.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 (168) hide show
  1. package/README.md +183 -96
  2. package/deploy/local/docker-compose.yml +5 -5
  3. package/dist/claude-coding-worker/driver.d.ts +1 -0
  4. package/dist/claude-coding-worker/driver.js +4 -1
  5. package/dist/claude-coding-worker/main.js +3 -2
  6. package/dist/cli-help.d.ts +1 -1
  7. package/dist/cli-help.js +5 -0
  8. package/dist/cli.js +0 -0
  9. package/dist/coding/protocol.d.ts +28 -3
  10. package/dist/coding/protocol.js +53 -2
  11. package/dist/coding-worker/artifact.d.ts +12 -0
  12. package/dist/coding-worker/artifact.js +48 -6
  13. package/dist/coding-worker/driver.d.ts +1 -0
  14. package/dist/coding-worker/driver.js +4 -1
  15. package/dist/coding-worker/errors.d.ts +7 -0
  16. package/dist/coding-worker/errors.js +18 -0
  17. package/dist/coding-worker/main.js +3 -2
  18. package/dist/core/budget-groups.d.ts +1 -1
  19. package/dist/core/coding-queue.d.ts +1 -1
  20. package/dist/core/datastores.d.ts +1 -1
  21. package/dist/core/db.d.ts +36 -2
  22. package/dist/core/db.js +69 -2
  23. package/dist/core/dispatch.d.ts +16 -1
  24. package/dist/core/dispatch.js +33 -5
  25. package/dist/core/http-runtime.js +3 -4
  26. package/dist/core/lease.d.ts +1 -1
  27. package/dist/core/private-directory.d.ts +20 -0
  28. package/dist/core/private-directory.js +36 -0
  29. package/dist/core/reconciler.d.ts +1 -1
  30. package/dist/core/runner.d.ts +1 -1
  31. package/dist/core/scheduler.d.ts +1 -1
  32. package/dist/core/secrets.d.ts +1 -1
  33. package/dist/core/subagent-memory-tools.d.ts +1 -1
  34. package/dist/core/webhooks.d.ts +1 -1
  35. package/dist/env.js +8 -1
  36. package/dist/generated/prisma/browser.d.ts +228 -0
  37. package/dist/generated/prisma/browser.js +17 -0
  38. package/dist/generated/prisma/client.d.ts +247 -0
  39. package/dist/generated/prisma/client.js +34 -0
  40. package/dist/generated/prisma/commonInputTypes.d.ts +809 -0
  41. package/dist/generated/prisma/commonInputTypes.js +10 -0
  42. package/dist/generated/prisma/enums.d.ts +53 -0
  43. package/dist/generated/prisma/enums.js +54 -0
  44. package/dist/generated/prisma/internal/class.d.ts +462 -0
  45. package/dist/generated/prisma/internal/class.js +49 -0
  46. package/dist/generated/prisma/internal/prismaNamespace.d.ts +3325 -0
  47. package/dist/generated/prisma/internal/prismaNamespace.js +455 -0
  48. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +449 -0
  49. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +426 -0
  50. package/dist/generated/prisma/models/Agent.d.ts +3267 -0
  51. package/dist/generated/prisma/models/Agent.js +1 -0
  52. package/dist/generated/prisma/models/AgentDatastore.d.ts +1210 -0
  53. package/dist/generated/prisma/models/AgentDatastore.js +1 -0
  54. package/dist/generated/prisma/models/AgentMemory.d.ts +986 -0
  55. package/dist/generated/prisma/models/AgentMemory.js +1 -0
  56. package/dist/generated/prisma/models/AgentSecret.d.ts +1212 -0
  57. package/dist/generated/prisma/models/AgentSecret.js +1 -0
  58. package/dist/generated/prisma/models/AgentSubAgent.d.ts +1217 -0
  59. package/dist/generated/prisma/models/AgentSubAgent.js +1 -0
  60. package/dist/generated/prisma/models/AgentTool.d.ts +1309 -0
  61. package/dist/generated/prisma/models/AgentTool.js +1 -0
  62. package/dist/generated/prisma/models/AuthFormChallenge.d.ts +1298 -0
  63. package/dist/generated/prisma/models/AuthFormChallenge.js +1 -0
  64. package/dist/generated/prisma/models/AuthLoginKey.d.ts +1245 -0
  65. package/dist/generated/prisma/models/AuthLoginKey.js +1 -0
  66. package/dist/generated/prisma/models/AuthRateLimit.d.ts +979 -0
  67. package/dist/generated/prisma/models/AuthRateLimit.js +1 -0
  68. package/dist/generated/prisma/models/AuthSession.d.ts +1376 -0
  69. package/dist/generated/prisma/models/AuthSession.js +1 -0
  70. package/dist/generated/prisma/models/AuthUser.d.ts +1645 -0
  71. package/dist/generated/prisma/models/AuthUser.js +1 -0
  72. package/dist/generated/prisma/models/BudgetGroup.d.ts +1548 -0
  73. package/dist/generated/prisma/models/BudgetGroup.js +1 -0
  74. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +1430 -0
  75. package/dist/generated/prisma/models/CodingAgentProfile.js +1 -0
  76. package/dist/generated/prisma/models/CodingProxyRequest.d.ts +1749 -0
  77. package/dist/generated/prisma/models/CodingProxyRequest.js +1 -0
  78. package/dist/generated/prisma/models/CodingProxySession.d.ts +1505 -0
  79. package/dist/generated/prisma/models/CodingProxySession.js +1 -0
  80. package/dist/generated/prisma/models/CodingRun.d.ts +2402 -0
  81. package/dist/generated/prisma/models/CodingRun.js +1 -0
  82. package/dist/generated/prisma/models/Datastore.d.ts +1434 -0
  83. package/dist/generated/prisma/models/Datastore.js +1 -0
  84. package/dist/generated/prisma/models/DatastoreEntry.d.ts +1314 -0
  85. package/dist/generated/prisma/models/DatastoreEntry.js +1 -0
  86. package/dist/generated/prisma/models/OAuthAuthorizationCode.d.ts +1526 -0
  87. package/dist/generated/prisma/models/OAuthAuthorizationCode.js +1 -0
  88. package/dist/generated/prisma/models/OAuthAuthorizationRequest.d.ts +1350 -0
  89. package/dist/generated/prisma/models/OAuthAuthorizationRequest.js +1 -0
  90. package/dist/generated/prisma/models/OAuthClient.d.ts +1289 -0
  91. package/dist/generated/prisma/models/OAuthClient.js +1 -0
  92. package/dist/generated/prisma/models/OAuthFamily.d.ts +1540 -0
  93. package/dist/generated/prisma/models/OAuthFamily.js +1 -0
  94. package/dist/generated/prisma/models/OAuthGrant.d.ts +1280 -0
  95. package/dist/generated/prisma/models/OAuthGrant.js +1 -0
  96. package/dist/generated/prisma/models/Principal.d.ts +1790 -0
  97. package/dist/generated/prisma/models/Principal.js +1 -0
  98. package/dist/generated/prisma/models/Run.d.ts +2281 -0
  99. package/dist/generated/prisma/models/Run.js +1 -0
  100. package/dist/generated/prisma/models/SchedulerLease.d.ts +970 -0
  101. package/dist/generated/prisma/models/SchedulerLease.js +1 -0
  102. package/dist/generated/prisma/models/Secret.d.ts +1399 -0
  103. package/dist/generated/prisma/models/Secret.js +1 -0
  104. package/dist/generated/prisma/models/SecretElicitationOutcome.d.ts +973 -0
  105. package/dist/generated/prisma/models/SecretElicitationOutcome.js +1 -0
  106. package/dist/generated/prisma/models/Task.d.ts +1176 -0
  107. package/dist/generated/prisma/models/Task.js +1 -0
  108. package/dist/generated/prisma/models/Tool.d.ts +1478 -0
  109. package/dist/generated/prisma/models/Tool.js +1 -0
  110. package/dist/generated/prisma/models/Webhook.d.ts +1386 -0
  111. package/dist/generated/prisma/models/Webhook.js +1 -0
  112. package/dist/generated/prisma/models.d.ts +32 -0
  113. package/dist/generated/prisma/models.js +1 -0
  114. package/dist/import/create.d.ts +1 -1
  115. package/dist/import/create.js +1 -1
  116. package/dist/import/index.d.ts +1 -1
  117. package/dist/mcp/auth/ownership.d.ts +19 -19
  118. package/dist/mcp/auth/principal.d.ts +1 -1
  119. package/dist/mcp/auth/resource-server.d.ts +1 -1
  120. package/dist/mcp/auth/self-hosted/browser.js +20 -6
  121. package/dist/mcp/auth/self-hosted/cli.d.ts +1 -1
  122. package/dist/mcp/auth/self-hosted/credentials.d.ts +1 -1
  123. package/dist/mcp/auth/self-hosted/rate-limit.d.ts +1 -1
  124. package/dist/mcp/auth/self-hosted/session.d.ts +1 -1
  125. package/dist/mcp/context.d.ts +1 -1
  126. package/dist/mcp/server.d.ts +1 -1
  127. package/dist/mcp/shared/page-style.d.ts +16 -0
  128. package/dist/mcp/shared/page-style.js +129 -0
  129. package/dist/mcp/tasks/manager.d.ts +1 -1
  130. package/dist/mcp/tools/agents.js +1 -1
  131. package/dist/mcp/tools/datastore.js +1 -1
  132. package/dist/mcp/tools/scheduling.js +1 -1
  133. package/dist/mcp/tools/secret-elicitation-form.d.ts +1 -1
  134. package/dist/mcp/tools/secret-elicitation-form.js +10 -28
  135. package/dist/mcp/tools/secret-elicitation.d.ts +1 -1
  136. package/dist/mcp/tools/subagents.js +1 -1
  137. package/dist/mcp/tools/tools.js +1 -1
  138. package/dist/mcp/transport/streamable-http.d.ts +1 -1
  139. package/dist/mcp/unattended-schedules.d.ts +1 -1
  140. package/dist/mcp/webhooks/ingress.d.ts +1 -1
  141. package/dist/providers/auth/index.d.ts +1 -1
  142. package/dist/providers/auth/self-hosted.d.ts +2 -2
  143. package/dist/providers/coding-proxy/prisma-ledger.d.ts +1 -1
  144. package/dist/providers/coding-proxy/runtime.d.ts +1 -1
  145. package/dist/providers/datastore/postgres.d.ts +1 -1
  146. package/dist/providers/executor/build.d.ts +1 -1
  147. package/dist/providers/executor/composition.d.ts +1 -1
  148. package/dist/providers/executor/container.d.ts +1 -1
  149. package/dist/providers/executor/container.js +10 -3
  150. package/dist/providers/executor/dbos.d.ts +1 -1
  151. package/dist/providers/jobs/docker.d.ts +11 -0
  152. package/dist/providers/jobs/docker.js +34 -18
  153. package/dist/providers/jobs/kubernetes.js +5 -10
  154. package/dist/providers/jobs/types.d.ts +2 -0
  155. package/dist/providers/memory/postgres.d.ts +1 -1
  156. package/dist/providers/vcs/git.js +2 -1
  157. package/dist/quickstart/config.d.ts +30 -0
  158. package/dist/quickstart/config.js +106 -0
  159. package/dist/quickstart/index.d.ts +5 -0
  160. package/dist/quickstart/index.js +527 -0
  161. package/dist/quickstart/migrate.d.ts +23 -0
  162. package/dist/quickstart/migrate.js +125 -0
  163. package/dist/wardby-bin.js +35 -9
  164. package/package.json +22 -9
  165. package/prisma/migrate.config.mjs +20 -0
  166. package/prisma/schema.prisma +10 -2
  167. package/dist/providers/auth/release-gate.d.ts +0 -1
  168. package/dist/providers/auth/release-gate.js +0 -4
@@ -6,6 +6,14 @@ export const MAX_CODING_SUMMARY_BYTES = 8 * 1024;
6
6
  export const MAX_CODING_TESTS = 64;
7
7
  export const MAX_CODING_TEST_COMMAND_BYTES = 2 * 1024;
8
8
  export const MAX_TAG_BYTES = 32;
9
+ /**
10
+ * A worker's output-schema failure, reduced to where and how it failed, never
11
+ * what the model wrote: `<path>:<zod issue code>`, where the path is dot-joined
12
+ * schema keys and array indices (`$` for the root). Unrecognized keys the model
13
+ * invented are carried only as the `unrecognized_keys` code, never by name.
14
+ */
15
+ export const MAX_CODING_OUTPUT_ISSUES = 8;
16
+ export const SAFE_CODING_OUTPUT_ISSUE = /^(?:\$|[a-z][A-Za-z]{0,31}(?:\.(?:[a-z][A-Za-z]{0,31}|\d{1,3})){0,7}):[a-z_]{1,32}$/;
9
17
  const MAX_REPOSITORY_INPUT_BYTES = 512;
10
18
  const MAX_REF_BYTES = 255;
11
19
  const MAX_MODEL_BYTES = 128;
@@ -108,6 +116,24 @@ const repositorySchema = z
108
116
  .transform(normalizeGitHubRepository);
109
117
  export const CodingBaseRefSchema = z.string().refine(isGitRef, "must be a safe branch ref").transform(normalizeGitRef);
110
118
  export const CodingTaskOverrideSchema = boundedText(MAX_CODING_TASK_BYTES);
119
+ /**
120
+ * A coding worker receives only its task text, so a coding agent's own
121
+ * instructions (its systemPrompt) travel inside that text, ahead of the
122
+ * request. Blank instructions leave the task unchanged. The combination must
123
+ * still fit MAX_CODING_TASK_BYTES; exceeding it is an error naming both parts
124
+ * rather than a silent truncation of either.
125
+ */
126
+ export function composeCodingTask(instructions, task) {
127
+ const standing = instructions?.trim();
128
+ if (!standing)
129
+ return task;
130
+ const composed = `Standing instructions for this coding agent:\n${standing}\n\nRequest:\n${task}`;
131
+ if (byteLength(composed) > MAX_CODING_TASK_BYTES) {
132
+ throw new Error(`The coding agent's instructions (${byteLength(standing)} bytes) plus this task (${byteLength(task)} bytes) ` +
133
+ `exceed the ${MAX_CODING_TASK_BYTES}-byte coding task limit; shorten one of them.`);
134
+ }
135
+ return composed;
136
+ }
111
137
  const runIdSchema = boundedText(MAX_RUN_ID_BYTES, true).refine((value) => /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value), "must be an opaque identifier");
112
138
  const modelSchema = boundedText(MAX_MODEL_BYTES, true).refine((value) => /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value), "must be a model identifier");
113
139
  const usageSchema = z
@@ -117,6 +143,30 @@ const usageSchema = z
117
143
  costUsd: z.number().finite().nonnegative().max(MAX_COST_USD),
118
144
  })
119
145
  .strict();
146
+ const SAFE_TAG = new RegExp(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,${MAX_TAG_BYTES - 1}}$`);
147
+ /**
148
+ * The tag only labels a pull request title, so a model's malformed tag must not
149
+ * sink an otherwise finished run. A tag that already passes is kept as-is; any
150
+ * other string becomes a lowercase slug (runs of disallowed characters to "-",
151
+ * no leading or trailing punctuation, at most MAX_TAG_BYTES), and one with
152
+ * nothing usable left, or a non-string, becomes null. GitHub finalization still
153
+ * re-validates whatever survives.
154
+ */
155
+ export function normalizeCodingTag(value) {
156
+ if (value === undefined || value === null)
157
+ return value;
158
+ if (typeof value !== "string")
159
+ return null;
160
+ if (SAFE_TAG.test(value))
161
+ return value;
162
+ const slug = value
163
+ .toLowerCase()
164
+ .replace(/[^a-z0-9._/-]+/g, "-")
165
+ .replace(/^[^a-z0-9]+/, "")
166
+ .slice(0, MAX_TAG_BYTES)
167
+ .replace(/[^a-z0-9]+$/, "");
168
+ return SAFE_TAG.test(slug) ? slug : null;
169
+ }
120
170
  /**
121
171
  * A short, caller-visible reference (e.g. a ticket ID) surfaced in the PR title. Never free text.
122
172
  * Accepts null as well as undefined: OpenAI's strict Structured Outputs mode requires every
@@ -126,7 +176,7 @@ const usageSchema = z
126
176
  */
127
177
  const tagSchema = z
128
178
  .string()
129
- .regex(new RegExp(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,${MAX_TAG_BYTES - 1}}$`), "must be a short, safe tag")
179
+ .regex(SAFE_TAG, "must be a short, safe tag")
130
180
  .nullable()
131
181
  .optional()
132
182
  .transform((value) => value ?? undefined);
@@ -175,7 +225,8 @@ export const CodingAgentOutputSchema = z
175
225
  outcome: z.enum(["changes_ready", "no_changes", "budget_exhausted"]),
176
226
  summary: boundedText(MAX_CODING_SUMMARY_BYTES),
177
227
  tests: z.array(testResultSchema).max(MAX_CODING_TESTS),
178
- tag: tagSchema,
228
+ // Model-written, so normalized rather than rejected; see normalizeCodingTag.
229
+ tag: z.preprocess(normalizeCodingTag, tagSchema),
179
230
  })
180
231
  .strict()
181
232
  .transform((value) => ({
@@ -1,4 +1,16 @@
1
1
  import { type CodingAgentOutput } from "../coding/protocol.js";
2
+ /**
3
+ * Reads a regular file of at most `maxBytes`, refusing symlinks and anything
4
+ * that is not a regular file.
5
+ *
6
+ * Every check runs against the open handle, never against the path before
7
+ * opening it: an lstat followed by a read of the same path leaves a window in
8
+ * which the file can be swapped for a symlink or a larger file. O_NOFOLLOW
9
+ * refuses a symlink at open time, O_NONBLOCK keeps a FIFO from hanging the
10
+ * open, and fstat on the descriptor checks what was actually opened. The lstat
11
+ * afterwards covers platforms without O_NOFOLLOW: it must be the same inode.
12
+ */
13
+ export declare function readBoundedRegularFile(path: string, maxBytes: number, errorCode: string): Promise<string>;
2
14
  export declare function readCodingInput(path: string): Promise<{
3
15
  repository: string;
4
16
  schemaVersion: 1;
@@ -1,12 +1,54 @@
1
- import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  import { MAX_CODING_ARTIFACT_BYTES, parseCodingTaskInputJson } from "../coding/protocol.js";
4
- export async function readCodingInput(path) {
5
- const metadata = await lstat(path);
6
- if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_CODING_ARTIFACT_BYTES) {
7
- throw new Error("coding_input_invalid_file");
5
+ /**
6
+ * Reads a regular file of at most `maxBytes`, refusing symlinks and anything
7
+ * that is not a regular file.
8
+ *
9
+ * Every check runs against the open handle, never against the path before
10
+ * opening it: an lstat followed by a read of the same path leaves a window in
11
+ * which the file can be swapped for a symlink or a larger file. O_NOFOLLOW
12
+ * refuses a symlink at open time, O_NONBLOCK keeps a FIFO from hanging the
13
+ * open, and fstat on the descriptor checks what was actually opened. The lstat
14
+ * afterwards covers platforms without O_NOFOLLOW: it must be the same inode.
15
+ */
16
+ export async function readBoundedRegularFile(path, maxBytes, errorCode) {
17
+ let file;
18
+ try {
19
+ file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
20
+ }
21
+ catch (error) {
22
+ // ELOOP: the path is a symlink.
23
+ if (error.code === "ELOOP")
24
+ throw new Error(errorCode, { cause: error });
25
+ throw error;
26
+ }
27
+ try {
28
+ const metadata = await file.stat();
29
+ if (!metadata.isFile() || metadata.size > maxBytes)
30
+ throw new Error(errorCode);
31
+ const link = await lstat(path);
32
+ if (link.isSymbolicLink() || link.ino !== metadata.ino || link.dev !== metadata.dev)
33
+ throw new Error(errorCode);
34
+ const buffer = Buffer.alloc(maxBytes + 1);
35
+ let length = 0;
36
+ while (length < buffer.length) {
37
+ const { bytesRead } = await file.read(buffer, length, buffer.length - length, length);
38
+ if (bytesRead === 0)
39
+ break;
40
+ length += bytesRead;
41
+ }
42
+ if (length > maxBytes)
43
+ throw new Error(errorCode);
44
+ return buffer.subarray(0, length).toString("utf8");
45
+ }
46
+ finally {
47
+ await file.close();
8
48
  }
9
- return parseCodingTaskInputJson(await readFile(path, "utf8"));
49
+ }
50
+ export async function readCodingInput(path) {
51
+ return parseCodingTaskInputJson(await readBoundedRegularFile(path, MAX_CODING_ARTIFACT_BYTES, "coding_input_invalid_file"));
10
52
  }
11
53
  export async function writeCodingOutputAtomic(path, output) {
12
54
  const directory = dirname(path);
@@ -20,6 +20,7 @@ export declare const CODING_OUTPUT_JSON_SCHEMA: {
20
20
  };
21
21
  readonly tag: {
22
22
  readonly type: readonly ["string", "null"];
23
+ readonly description: "Optional short label shown in the pull request title, such as a ticket id. At most 32 characters: letters, digits, '.', '_', '/', '-', starting with a letter or digit, with no spaces. Example: \"add-jokes\". Use null when there is no natural label.";
23
24
  };
24
25
  readonly tests: {
25
26
  readonly type: "array";
@@ -20,7 +20,10 @@ export const CODING_OUTPUT_JSON_SCHEMA = {
20
20
  runId: { type: "string" },
21
21
  outcome: { type: "string", enum: ["changes_ready", "no_changes", "budget_exhausted"] },
22
22
  summary: { type: "string" },
23
- tag: { type: ["string", "null"] },
23
+ tag: {
24
+ type: ["string", "null"],
25
+ description: "Optional short label shown in the pull request title, such as a ticket id. At most 32 characters: letters, digits, '.', '_', '/', '-', starting with a letter or digit, with no spaces. Example: \"add-jokes\". Use null when there is no natural label.",
26
+ },
24
27
  tests: {
25
28
  type: "array",
26
29
  maxItems: 64,
@@ -1 +1,8 @@
1
+ /**
2
+ * For `coding_output_invalid`, where the model's final answer failed the output
3
+ * schema: the failing schema paths and issue codes, so the operator log can say
4
+ * which field was wrong without ever carrying the value. Undefined when the
5
+ * cause is not a schema failure.
6
+ */
7
+ export declare function safeOutputIssues(error: unknown): string[] | undefined;
1
8
  export declare function safeWorkerErrorCode(error: unknown): string;
@@ -1,3 +1,5 @@
1
+ import { ZodError } from "zod";
2
+ import { MAX_CODING_OUTPUT_ISSUES, SAFE_CODING_OUTPUT_ISSUE } from "../coding/protocol.js";
1
3
  const SAFE_WORKER_ERROR_CODES = new Set([
2
4
  "wardby_proxy_url_missing",
3
5
  "wardby_run_capability_missing",
@@ -13,6 +15,22 @@ const SAFE_WORKER_ERROR_CODES = new Set([
13
15
  "coding_output_invalid",
14
16
  "coding_output_run_mismatch",
15
17
  ]);
18
+ /**
19
+ * For `coding_output_invalid`, where the model's final answer failed the output
20
+ * schema: the failing schema paths and issue codes, so the operator log can say
21
+ * which field was wrong without ever carrying the value. Undefined when the
22
+ * cause is not a schema failure.
23
+ */
24
+ export function safeOutputIssues(error) {
25
+ const cause = error instanceof Error ? error.cause : undefined;
26
+ if (!(cause instanceof ZodError))
27
+ return undefined;
28
+ const issues = cause.issues
29
+ .map((issue) => `${issue.path.length === 0 ? "$" : issue.path.join(".")}:${issue.code}`)
30
+ .filter((issue) => SAFE_CODING_OUTPUT_ISSUE.test(issue))
31
+ .slice(0, MAX_CODING_OUTPUT_ISSUES);
32
+ return issues.length > 0 ? issues : undefined;
33
+ }
16
34
  export function safeWorkerErrorCode(error) {
17
35
  if (!(error instanceof Error))
18
36
  return "worker_failed";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readCodingInput, writeCodingOutputAtomic } from "./artifact.js";
3
3
  import { runCodingWorker } from "./driver.js";
4
- import { safeWorkerErrorCode } from "./errors.js";
4
+ import { safeOutputIssues, safeWorkerErrorCode } from "./errors.js";
5
5
  import { createCodexSdkClient } from "./sdk.js";
6
6
  const INPUT_PATH = "/run/wardby/input/input.json";
7
7
  const OUTPUT_PATH = "/run/wardby/output/result.json";
@@ -38,6 +38,7 @@ catch (error) {
38
38
  : safeCode === "worker_failed"
39
39
  ? `worker_${stage}_failed`
40
40
  : safeCode;
41
- process.stderr.write(`${JSON.stringify({ error: code })}\n`);
41
+ const issues = code === "coding_output_invalid" ? safeOutputIssues(error) : undefined;
42
+ process.stderr.write(`${JSON.stringify({ error: code, ...(issues ? { issues } : {}) })}\n`);
42
43
  process.exitCode = controller.signal.aborted ? 143 : 1;
43
44
  }
@@ -12,7 +12,7 @@
12
12
  * budget of 0, which the engine's turn-1 gate already refuses exactly
13
13
  * like any other zero/negative budget.
14
14
  */
15
- import type { Agent, BudgetGroup, PrismaClient } from "@prisma/client";
15
+ import type { Agent, BudgetGroup, PrismaClient } from "#prisma";
16
16
  export type Period = "day" | "week" | "month";
17
17
  export type BudgetGroupsDb = Pick<PrismaClient, "budgetGroup" | "run" | "agent">;
18
18
  /**
@@ -14,7 +14,7 @@
14
14
  * Called on the scheduler leader's tick and immediately in any process
15
15
  * whose coding run just finished (ContainerExecutor onSlotReleased).
16
16
  */
17
- import type { PrismaClient } from "@prisma/client";
17
+ import type { PrismaClient } from "#prisma";
18
18
  import type { Executor } from "../providers/executor/types.js";
19
19
  export declare const CODING_QUEUE_TIMEOUT_ERROR = "coding_queue_timeout";
20
20
  export type CodingQueueDb = Pick<PrismaClient, "run" | "codingRun" | "$transaction">;
@@ -4,7 +4,7 @@
4
4
  * by a per-attachment `boundName` (sharedDatastore.get(boundName, key)) —
5
5
  * same boundName convention as AgentSecret.
6
6
  */
7
- import type { PrismaClient, Datastore as DatastoreRow } from "@prisma/client";
7
+ import type { PrismaClient, Datastore as DatastoreRow } from "#prisma";
8
8
  import type { Datastore, DatastoreSetOptions, DatastoreValue } from "../providers/datastore/types.js";
9
9
  export type DatastoreMetadata = Pick<DatastoreRow, "id" | "name" | "ownerId" | "createdAt" | "updatedAt">;
10
10
  export interface SharedDatastoreAccessor {
package/dist/core/db.d.ts CHANGED
@@ -1,2 +1,36 @@
1
- import { PrismaClient } from "@prisma/client";
2
- export declare const prisma: PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
1
+ import { PrismaClient } from "#prisma";
2
+ /**
3
+ * Connection-pool sizing for the node-postgres pool behind @prisma/adapter-pg.
4
+ *
5
+ * Prisma 6's query engine owned the pool: `connection_limit` defaulted to
6
+ * cpus * 2 + 1 and a caller waited at most `pool_timeout` = 10 s for a free
7
+ * connection (then P2024). Prisma 7 hands the pool to `pg`, whose defaults are
8
+ * max = 10 and connectionTimeoutMillis = 0 — wait for a connection *forever*,
9
+ * so a saturated pool would hang requests instead of failing them. Both values
10
+ * are therefore set explicitly, to Prisma 6's defaults:
11
+ *
12
+ * - max: availableParallelism() * 2 + 1 (Prisma 6 counted physical cores; the
13
+ * logical count is the closest portable Node equivalent).
14
+ * - connectionTimeoutMillis: 10 000 (Prisma 6's pool_timeout). A timeout now
15
+ * surfaces as a pg "timeout exceeded when trying to connect" error, not P2024.
16
+ *
17
+ * `pg` ignores Prisma's own URL parameters, so the two that tuned these same
18
+ * knobs (`connection_limit`, `pool_timeout` in seconds) are still honoured here
19
+ * for URLs that set them. idleTimeoutMillis stays at pg's 10 s default.
20
+ *
21
+ * TLS is whatever the URL asks for (`sslmode`, `sslrootcert`, ...); certificate
22
+ * validation is never disabled here.
23
+ */
24
+ export declare const DEFAULT_POOL_TIMEOUT_MS = 10000;
25
+ export declare function defaultPoolMax(): number;
26
+ export interface PoolSettings {
27
+ max: number;
28
+ connectionTimeoutMillis: number;
29
+ }
30
+ export declare function poolSettings(connectionString: string): PoolSettings;
31
+ /**
32
+ * The one way wardby builds a Prisma client. `url` defaults to DATABASE_URL
33
+ * as read at call time.
34
+ */
35
+ export declare function createPrismaClient(url?: string | undefined): PrismaClient;
36
+ export declare const prisma: PrismaClient;
package/dist/core/db.js CHANGED
@@ -1,2 +1,69 @@
1
- import { PrismaClient } from "@prisma/client";
2
- export const prisma = new PrismaClient();
1
+ import { availableParallelism } from "node:os";
2
+ import { PrismaPg } from "@prisma/adapter-pg";
3
+ import { PrismaClient } from "#prisma";
4
+ /**
5
+ * Connection-pool sizing for the node-postgres pool behind @prisma/adapter-pg.
6
+ *
7
+ * Prisma 6's query engine owned the pool: `connection_limit` defaulted to
8
+ * cpus * 2 + 1 and a caller waited at most `pool_timeout` = 10 s for a free
9
+ * connection (then P2024). Prisma 7 hands the pool to `pg`, whose defaults are
10
+ * max = 10 and connectionTimeoutMillis = 0 — wait for a connection *forever*,
11
+ * so a saturated pool would hang requests instead of failing them. Both values
12
+ * are therefore set explicitly, to Prisma 6's defaults:
13
+ *
14
+ * - max: availableParallelism() * 2 + 1 (Prisma 6 counted physical cores; the
15
+ * logical count is the closest portable Node equivalent).
16
+ * - connectionTimeoutMillis: 10 000 (Prisma 6's pool_timeout). A timeout now
17
+ * surfaces as a pg "timeout exceeded when trying to connect" error, not P2024.
18
+ *
19
+ * `pg` ignores Prisma's own URL parameters, so the two that tuned these same
20
+ * knobs (`connection_limit`, `pool_timeout` in seconds) are still honoured here
21
+ * for URLs that set them. idleTimeoutMillis stays at pg's 10 s default.
22
+ *
23
+ * TLS is whatever the URL asks for (`sslmode`, `sslrootcert`, ...); certificate
24
+ * validation is never disabled here.
25
+ */
26
+ export const DEFAULT_POOL_TIMEOUT_MS = 10_000;
27
+ export function defaultPoolMax() {
28
+ return availableParallelism() * 2 + 1;
29
+ }
30
+ export function poolSettings(connectionString) {
31
+ const settings = { max: defaultPoolMax(), connectionTimeoutMillis: DEFAULT_POOL_TIMEOUT_MS };
32
+ let params;
33
+ try {
34
+ params = new URL(connectionString).searchParams;
35
+ }
36
+ catch {
37
+ return settings;
38
+ }
39
+ const limit = Number(params.get("connection_limit"));
40
+ if (Number.isInteger(limit) && limit > 0)
41
+ settings.max = limit;
42
+ const timeout = params.get("pool_timeout");
43
+ if (timeout !== null && timeout !== "" && Number.isFinite(Number(timeout)) && Number(timeout) >= 0) {
44
+ // Prisma's pool_timeout=0 meant "no timeout"; pg's 0 means the same.
45
+ settings.connectionTimeoutMillis = Number(timeout) * 1000;
46
+ }
47
+ return settings;
48
+ }
49
+ /**
50
+ * Without a URL, pg would silently fall back to PGHOST/localhost:5432 as the
51
+ * OS user. Prisma 6 failed the first query instead ("Environment variable not
52
+ * found: DATABASE_URL"); keep that: construction succeeds, connecting fails.
53
+ */
54
+ class MissingUrlAdapter extends PrismaPg {
55
+ async connect() {
56
+ throw new Error("DATABASE_URL is not set: cannot connect to the database.");
57
+ }
58
+ }
59
+ /**
60
+ * The one way wardby builds a Prisma client. `url` defaults to DATABASE_URL
61
+ * as read at call time.
62
+ */
63
+ export function createPrismaClient(url = process.env.DATABASE_URL) {
64
+ const adapter = url
65
+ ? new PrismaPg({ connectionString: url, ...poolSettings(url) })
66
+ : new MissingUrlAdapter({ connectionString: "" });
67
+ return new PrismaClient({ adapter });
68
+ }
69
+ export const prisma = createPrismaClient();
@@ -1,4 +1,4 @@
1
- import type { Prisma, PrismaClient, Run, RunTrigger, Task } from "@prisma/client";
1
+ import type { Prisma, PrismaClient, Run, RunTrigger, Task } from "#prisma";
2
2
  import type { Executor } from "../providers/executor/types.js";
3
3
  export type DispatchDb = Pick<PrismaClient, "agent" | "run" | "codingRun" | "task" | "webhook" | "$transaction" | "$queryRaw">;
4
4
  type DispatchTx = Pick<Prisma.TransactionClient, "agent" | "run" | "codingRun" | "task" | "webhook" | "$queryRaw">;
@@ -63,6 +63,21 @@ export interface DispatchRunResult {
63
63
  run: Run;
64
64
  task?: Task;
65
65
  }
66
+ /**
67
+ * A transient transaction conflict in the Serializable persist transaction:
68
+ * a serialization failure (40001) or a deadlock (40P01). It arrives in one of
69
+ * three shapes:
70
+ * - on a model statement: P2034;
71
+ * - on a raw statement ($queryRaw, e.g. the FOR UPDATE SKIP LOCKED below, or
72
+ * anything a beforePersist callback runs): P2010, with the SQLSTATE at
73
+ * meta.driverAdapterError.cause.originalCode under the pg driver adapter
74
+ * (Prisma 7) -- Prisma 6's engine put 40001 at meta.code, still accepted;
75
+ * - at COMMIT (e.g. SSI write skew): an unwrapped DriverAdapterError, no
76
+ * code or meta, with the SQLSTATE at cause.originalCode.
77
+ *
78
+ * @internal Exported only for the real-PostgreSQL tests.
79
+ */
80
+ export declare function isSerializationConflict(err: unknown): boolean;
66
81
  /**
67
82
  * Record an executor-level failure on a run that never reached a terminal
68
83
  * state itself. Deliberately idempotent: the same failure can arrive twice —
@@ -1,12 +1,38 @@
1
- import { CODING_PROTOCOL_VERSION, CodingTaskInputSchema, normalizeGitHubRepository, publicCodingRunResult, } from "../coding/protocol.js";
1
+ import { CODING_PROTOCOL_VERSION, CodingTaskInputSchema, normalizeGitHubRepository, publicCodingRunResult, composeCodingTask, } from "../coding/protocol.js";
2
2
  import { assertCodingProviderModel } from "../coding/provider.js";
3
3
  import { logger } from "./logger.js";
4
4
  const dispatchLog = logger.child({ module: "dispatch" });
5
- function isSerializationConflict(err) {
5
+ /** SQLSTATEs worth retrying the persist transaction for: serialization failure, deadlock. */
6
+ const RETRYABLE_SQLSTATES = new Set(["40001", "40P01"]);
7
+ /**
8
+ * A transient transaction conflict in the Serializable persist transaction:
9
+ * a serialization failure (40001) or a deadlock (40P01). It arrives in one of
10
+ * three shapes:
11
+ * - on a model statement: P2034;
12
+ * - on a raw statement ($queryRaw, e.g. the FOR UPDATE SKIP LOCKED below, or
13
+ * anything a beforePersist callback runs): P2010, with the SQLSTATE at
14
+ * meta.driverAdapterError.cause.originalCode under the pg driver adapter
15
+ * (Prisma 7) -- Prisma 6's engine put 40001 at meta.code, still accepted;
16
+ * - at COMMIT (e.g. SSI write skew): an unwrapped DriverAdapterError, no
17
+ * code or meta, with the SQLSTATE at cause.originalCode.
18
+ *
19
+ * @internal Exported only for the real-PostgreSQL tests.
20
+ */
21
+ export function isSerializationConflict(err) {
6
22
  if (!err || typeof err !== "object")
7
23
  return false;
8
24
  const candidate = err;
9
- return candidate.code === "P2034" || (candidate.code === "P2010" && candidate.meta?.code === "40001");
25
+ if (candidate.code === "P2034")
26
+ return true;
27
+ if (candidate.code === "P2010") {
28
+ const sqlState = candidate.meta?.driverAdapterError?.cause?.originalCode;
29
+ return (typeof sqlState === "string" && RETRYABLE_SQLSTATES.has(sqlState)) || candidate.meta?.code === "40001";
30
+ }
31
+ if (candidate.name === "DriverAdapterError") {
32
+ const sqlState = candidate.cause?.originalCode;
33
+ return typeof sqlState === "string" && RETRYABLE_SQLSTATES.has(sqlState);
34
+ }
35
+ return false;
10
36
  }
11
37
  /**
12
38
  * Record an executor-level failure on a run that never reached a terminal
@@ -66,9 +92,11 @@ export async function dispatchRun(options) {
66
92
  if (!agent.codingProfile)
67
93
  throw new Error(`Coding agent "${agent.id}" has no coding profile.`);
68
94
  assertCodingProviderModel(agent.codingProfile.provider, agent.model);
69
- const task = options.codingTask ?? agent.codingProfile.defaultTask;
70
- if (!task)
95
+ const request = options.codingTask ?? agent.codingProfile.defaultTask;
96
+ if (!request)
71
97
  throw new Error(`Coding agent "${agent.id}" requires a task.`);
98
+ // The worker sees only the task text, so the agent's own instructions ride in it.
99
+ const task = composeCodingTask(agent.systemPrompt, request);
72
100
  const budgetUsd = options.budgetUsdOverride ?? Number(agent.budgetUsd);
73
101
  let baseRef = options.codingBaseRef ?? agent.codingProfile.baseRef;
74
102
  let headRef = `wardby/run-${run.id}`;
@@ -57,11 +57,10 @@
57
57
  * later in the process, would replace this Agent with an h2-enabled one and
58
58
  * silently restore the corruption — the price of fixing this with a dispatcher
59
59
  * instead of owning the fetch stack. Nothing in the runtime image does that
60
- * today: the only other `setGlobalDispatcher` calls in the tree are undici
61
- * copies bundled inside `@prisma/client/runtime/binary.*` (guarded by the same
62
- * `=== undefined` check, and the binary engine is not the default) and
60
+ * today: the only other `setGlobalDispatcher` call in the tree is
63
61
  * `node-fetch-native` under the `prisma` CLI, which `deploy/Dockerfile:25`
64
- * asserts is absent from the runtime image.
62
+ * asserts is absent from the runtime image. (Prisma 7's client runtime and
63
+ * `@prisma/adapter-pg` contain none; Prisma 6's `runtime/binary.*` did.)
65
64
  *
66
65
  * Restoring Node's OWN dispatcher instead is not possible, though not for the
67
66
  * reason one might guess: undici defines the legacy symbol `configurable: false`
@@ -7,7 +7,7 @@
7
7
  * expired, or already ours, and `RETURNING` tells the caller which case it
8
8
  * was — zero rows means someone else holds an unexpired lease.
9
9
  */
10
- import type { PrismaClient } from "@prisma/client";
10
+ import type { PrismaClient } from "#prisma";
11
11
  export type LeaseDb = Pick<PrismaClient, "$queryRaw">;
12
12
  /** Attempts to acquire (or renew) the lease for `scope`. Returns whether `holder` now holds it. */
13
13
  export declare function tryAcquireLease(db: LeaseDb, scope: string, holder: string, ttlMs: number): Promise<boolean>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Creates `path` as a private directory, or verifies an existing one is safe to
3
+ * trust.
4
+ *
5
+ * `mkdir(..., { mode: 0o700 })` does nothing to a directory that already
6
+ * exists. The coding state roots default to fixed names under the OS temp
7
+ * directory, so on a shared host another user could create one first -- or
8
+ * point it somewhere else with a symlink -- and then read or plant the job
9
+ * records, workspaces and artifacts wardby keeps there. This refuses:
10
+ *
11
+ * - a symlink, or anything that is not a directory;
12
+ * - a directory owned by a different user (root excepted: an operator-mounted
13
+ * volume is commonly root-owned);
14
+ * - a directory any other user can write to.
15
+ *
16
+ * Group write is allowed, since a Kubernetes `fsGroup` volume is group-writable
17
+ * by design and its group is chosen by the operator. Ownership and mode are not
18
+ * checked on platforms without POSIX uids.
19
+ */
20
+ export declare function ensurePrivateDirectory(path: string): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import { lstat, mkdir } from "node:fs/promises";
2
+ /**
3
+ * Creates `path` as a private directory, or verifies an existing one is safe to
4
+ * trust.
5
+ *
6
+ * `mkdir(..., { mode: 0o700 })` does nothing to a directory that already
7
+ * exists. The coding state roots default to fixed names under the OS temp
8
+ * directory, so on a shared host another user could create one first -- or
9
+ * point it somewhere else with a symlink -- and then read or plant the job
10
+ * records, workspaces and artifacts wardby keeps there. This refuses:
11
+ *
12
+ * - a symlink, or anything that is not a directory;
13
+ * - a directory owned by a different user (root excepted: an operator-mounted
14
+ * volume is commonly root-owned);
15
+ * - a directory any other user can write to.
16
+ *
17
+ * Group write is allowed, since a Kubernetes `fsGroup` volume is group-writable
18
+ * by design and its group is chosen by the operator. Ownership and mode are not
19
+ * checked on platforms without POSIX uids.
20
+ */
21
+ export async function ensurePrivateDirectory(path) {
22
+ await mkdir(path, { recursive: true, mode: 0o700 });
23
+ const metadata = await lstat(path);
24
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
25
+ throw new Error(`${path} must be a real directory, not a symlink.`);
26
+ }
27
+ const uid = process.getuid?.();
28
+ if (uid === undefined)
29
+ return;
30
+ if (metadata.uid !== uid && metadata.uid !== 0) {
31
+ throw new Error(`${path} is owned by uid ${metadata.uid}, not this process (uid ${uid}). Remove it or choose another path.`);
32
+ }
33
+ if ((metadata.mode & 0o002) !== 0) {
34
+ throw new Error(`${path} is writable by other users. Run chmod o-w on it or choose another path.`);
35
+ }
36
+ }
@@ -28,7 +28,7 @@
28
28
  * second one's WHERE simply matches zero rows — double-reconcile is a
29
29
  * no-op, not a race.
30
30
  */
31
- import type { PrismaClient } from "@prisma/client";
31
+ import type { PrismaClient } from "#prisma";
32
32
  import type { Executor } from "../providers/executor/types.js";
33
33
  export type ReconcilerDb = Pick<PrismaClient, "run">;
34
34
  /** Runs one reconciliation pass. Returns the number of runs marked `lost`. */
@@ -16,7 +16,7 @@
16
16
  * down) lives inside the engine now — there is exactly one place that
17
17
  * reasons about cost, not one here plus one in the engine.
18
18
  */
19
- import type { PrismaClient, Run, RunTrigger } from "@prisma/client";
19
+ import type { PrismaClient, Run, RunTrigger } from "#prisma";
20
20
  import type { ProviderRegistry } from "../providers/index.js";
21
21
  import { type StepRunner } from "../providers/engine/types.js";
22
22
  import type { Executor } from "../providers/executor/types.js";
@@ -6,7 +6,7 @@
6
6
  * create — never by the lease alone (the lease only prevents concurrent
7
7
  * *ticking*, not a race within a single tick against a future one).
8
8
  */
9
- import type { Agent } from "@prisma/client";
9
+ import type { Agent } from "#prisma";
10
10
  import type { Executor } from "../providers/executor/types.js";
11
11
  import { type DispatchDb } from "./dispatch.js";
12
12
  export type SchedulerDb = DispatchDb;
@@ -6,7 +6,7 @@
6
6
  * always resolve a name within one owner's (or, at get-time, one agent's)
7
7
  * scope, never globally.
8
8
  */
9
- import type { PrismaClient, Secret } from "@prisma/client";
9
+ import type { PrismaClient, Secret } from "#prisma";
10
10
  import type { SecretCipher } from "../providers/secrets/types.js";
11
11
  export type SecretMetadata = Pick<Secret, "id" | "name" | "keyId" | "ownerId" | "createdAt" | "updatedAt">;
12
12
  export interface SecretsAccessor {
@@ -1,4 +1,4 @@
1
- import type { PrismaClient } from "@prisma/client";
1
+ import type { PrismaClient } from "#prisma";
2
2
  import type { LoadedTool } from "../providers/engine/types.js";
3
3
  import type { AgentMemoryStore } from "../providers/memory/types.js";
4
4
  export declare const SUBAGENT_MEMORY_TOOL_NAMES: ReadonlySet<string>;
@@ -1,4 +1,4 @@
1
- import type { PrismaClient, Webhook } from "@prisma/client";
1
+ import type { PrismaClient, Webhook } from "#prisma";
2
2
  import type { Executor } from "../providers/executor/types.js";
3
3
  export type WebhookMetadata = Pick<Webhook, "id" | "agentId" | "status" | "ownerId" | "createdAt" | "lastFiredAt">;
4
4
  /** Creates a webhook and returns the raw secret ONCE — only its hash is ever stored. */
package/dist/env.js CHANGED
@@ -10,4 +10,11 @@
10
10
  */
11
11
  import "./core/http-runtime.js";
12
12
  import dotenvFlow from "dotenv-flow";
13
- dotenvFlow.config();
13
+ import { join, resolve } from "node:path";
14
+ const projectDir = resolve(process.env.WARDBY_PROJECT_DIR || process.cwd());
15
+ // A quickstart installation owns this directory, so load it before an
16
+ // application's root env files. dotenv-flow never overwrites an existing
17
+ // process variable, preserving explicit shell/container configuration as the
18
+ // highest-priority source in either mode.
19
+ dotenvFlow.config({ path: join(projectDir, ".wardby"), silent: true });
20
+ dotenvFlow.config({ path: projectDir });