@mingchuno/agent-workflows 0.1.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 (62) hide show
  1. package/LICENCE +21 -0
  2. package/README.md +74 -0
  3. package/dist/drizzle/0000_initial.sql +45 -0
  4. package/dist/drizzle/meta/0000_snapshot.json +264 -0
  5. package/dist/drizzle/meta/_journal.json +13 -0
  6. package/dist/src/adapters/agent-worker.d.ts +1 -0
  7. package/dist/src/adapters/agent-worker.js +16 -0
  8. package/dist/src/adapters/agents.d.ts +24 -0
  9. package/dist/src/adapters/agents.js +142 -0
  10. package/dist/src/adapters/hosting.d.ts +33 -0
  11. package/dist/src/adapters/hosting.js +275 -0
  12. package/dist/src/adapters/sdk-protocol.d.ts +43 -0
  13. package/dist/src/adapters/sdk-protocol.js +64 -0
  14. package/dist/src/cli.d.ts +2 -0
  15. package/dist/src/cli.js +175 -0
  16. package/dist/src/config.d.ts +224 -0
  17. package/dist/src/config.js +82 -0
  18. package/dist/src/db/locks.d.ts +4 -0
  19. package/dist/src/db/locks.js +14 -0
  20. package/dist/src/db/migrate.d.ts +1 -0
  21. package/dist/src/db/migrate.js +12 -0
  22. package/dist/src/db/migrations.d.ts +2 -0
  23. package/dist/src/db/migrations.js +22 -0
  24. package/dist/src/db/schema.d.ts +486 -0
  25. package/dist/src/db/schema.js +46 -0
  26. package/dist/src/domain.d.ts +133 -0
  27. package/dist/src/domain.js +24 -0
  28. package/dist/src/index.d.ts +8 -0
  29. package/dist/src/index.js +8 -0
  30. package/dist/src/operations.d.ts +35 -0
  31. package/dist/src/operations.js +378 -0
  32. package/dist/src/run-record.d.ts +7 -0
  33. package/dist/src/run-record.js +19 -0
  34. package/dist/src/runner.d.ts +47 -0
  35. package/dist/src/runner.js +370 -0
  36. package/dist/src/runtime/ownership.d.ts +8 -0
  37. package/dist/src/runtime/ownership.js +84 -0
  38. package/dist/src/runtime/process.d.ts +18 -0
  39. package/dist/src/runtime/process.js +98 -0
  40. package/dist/src/runtime/redaction.d.ts +8 -0
  41. package/dist/src/runtime/redaction.js +33 -0
  42. package/dist/src/store.d.ts +87 -0
  43. package/dist/src/store.js +355 -0
  44. package/dist/src/tui-data.d.ts +25 -0
  45. package/dist/src/tui-data.js +89 -0
  46. package/dist/src/tui.d.ts +5 -0
  47. package/dist/src/tui.js +69 -0
  48. package/dist/src/workspace.d.ts +16 -0
  49. package/dist/src/workspace.js +186 -0
  50. package/docs/acceptance.md +35 -0
  51. package/docs/api.md +64 -0
  52. package/docs/architecture.md +24 -0
  53. package/docs/configuration.md +41 -0
  54. package/docs/database.md +28 -0
  55. package/docs/operations.md +46 -0
  56. package/docs/providers.md +49 -0
  57. package/docs/releases.md +89 -0
  58. package/examples/config.ts +57 -0
  59. package/examples/custom-workflow.ts +32 -0
  60. package/examples/observe.ts +18 -0
  61. package/examples/run.ts +31 -0
  62. package/package.json +78 -0
@@ -0,0 +1,370 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, realpath } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { DBOS } from "@dbos-inc/dbos-sdk";
5
+ import { configSchema } from "./config.js";
6
+ import { BlockedError, isBlockedError, } from "./domain.js";
7
+ import { defaultWorkflow, Operations } from "./operations.js";
8
+ import { createQueuedRun } from "./run-record.js";
9
+ import { assertProcessesStopped, CheckoutOwnership, } from "./runtime/ownership.js";
10
+ import { createRedactor, runtimeLogger } from "./runtime/redaction.js";
11
+ import { Store } from "./store.js";
12
+ import { ExistingCheckout } from "./workspace.js";
13
+ export class Runner {
14
+ options;
15
+ store;
16
+ config;
17
+ controllers = new Map();
18
+ active = new Map();
19
+ hosting = new Map();
20
+ workflow;
21
+ ownership = new CheckoutOwnership();
22
+ stopping = false;
23
+ ownsRuntime = false;
24
+ tickBusy = false;
25
+ timer;
26
+ lastPoll = new Map();
27
+ polling = new Map();
28
+ constructor(options) {
29
+ this.options = options;
30
+ this.config = configSchema.parse(options.config);
31
+ this.store = new Store(options.databaseUrl, this.config.id, this.redact);
32
+ }
33
+ queue(id) {
34
+ return `${this.config.id}:${id}`;
35
+ }
36
+ async start() {
37
+ if (DBOS.isInitialized())
38
+ throw new Error("Only one Runner can own the DBOS runtime in a process");
39
+ if (process.platform === "win32")
40
+ throw new Error("Phase 1 requires macOS or Linux process-group ownership");
41
+ const canonical = new Set();
42
+ const ids = new Set();
43
+ for (const project of this.config.projects) {
44
+ project.checkout = await realpath(project.checkout);
45
+ if (canonical.has(project.checkout) || ids.has(project.id))
46
+ throw new Error("Duplicate project identity or canonical checkout");
47
+ canonical.add(project.checkout);
48
+ ids.add(project.id);
49
+ this.hosting.set(project.id, this.options.hosting(project));
50
+ }
51
+ for (const hosting of this.hosting.values())
52
+ await hosting.preflight?.();
53
+ await this.store.initialize();
54
+ await this.store.acquire([...canonical], () => {
55
+ this.stopping = true;
56
+ for (const controller of this.controllers.values())
57
+ controller.abort();
58
+ });
59
+ await assertProcessesStopped(resolve(this.config.stateDirectory));
60
+ for (const project of this.config.projects) {
61
+ await this.ownership.acquire(project.checkout, resolve(this.config.stateDirectory));
62
+ await this.store.registerProject(project.id);
63
+ }
64
+ await mkdir(resolve(this.config.stateDirectory), {
65
+ recursive: true,
66
+ mode: 0o700,
67
+ });
68
+ this.workflow = DBOS.registerWorkflow(async (runId) => this.execute(runId), { name: `${this.config.id}-issue-workflow` });
69
+ DBOS.setConfig({
70
+ name: `agent-workflows-${this.config.id}`,
71
+ systemDatabaseUrl: this.options.databaseUrl,
72
+ applicationVersion: `${this.config.id}-${this.options.workflowVersion ?? "phase1-v1"}`,
73
+ executorID: this.config.id,
74
+ listenQueues: this.config.projects.map((p) => this.queue(p.id)),
75
+ logger: runtimeLogger(this.redact),
76
+ });
77
+ this.ownsRuntime = true;
78
+ await DBOS.launch();
79
+ for (const project of this.config.projects)
80
+ await DBOS.registerQueue(this.queue(project.id), {
81
+ globalConcurrency: 1,
82
+ workerConcurrency: 1,
83
+ minPollingIntervalMs: 100,
84
+ });
85
+ this.timer = setInterval(() => {
86
+ void this.tick().catch((error) => this.store.emit(null, "runner-error", {
87
+ error: this.redact(String(error)),
88
+ }));
89
+ }, 100);
90
+ await this.tick();
91
+ }
92
+ redact = (text) => createRedactor([
93
+ this.options.databaseUrl,
94
+ ...this.config.projects.map((project) => process.env[project.hosting.tokenEnv] ?? ""),
95
+ ])(text);
96
+ async poll(projectId) {
97
+ await Promise.all(this.config.projects
98
+ .filter((p) => !projectId || p.id === projectId)
99
+ .map((project) => {
100
+ const pending = this.polling.get(project.id);
101
+ if (pending)
102
+ return pending;
103
+ if (this.stopping)
104
+ return Promise.resolve();
105
+ const polling = this.pollProject(project)
106
+ .catch(async (error) => {
107
+ await this.store.emit(null, "poll-error", {
108
+ projectId: project.id,
109
+ error: this.redact(String(error)),
110
+ });
111
+ throw error;
112
+ })
113
+ .finally(() => this.polling.delete(project.id));
114
+ this.polling.set(project.id, polling);
115
+ return polling;
116
+ }));
117
+ }
118
+ async pollProject(project) {
119
+ const state = await this.store.project(project.id);
120
+ if (state.paused || state.blocked || this.stopping)
121
+ return;
122
+ const hosting = this.hosting.get(project.id);
123
+ for (const issue of (await hosting.listIssues(project.labels)).sort((a, b) => a.number - b.number)) {
124
+ if (this.stopping)
125
+ return;
126
+ const now = new Date().toISOString();
127
+ const id = randomUUID();
128
+ const run = createQueuedRun({
129
+ id,
130
+ projectId: project.id,
131
+ checkout: project.checkout,
132
+ taskKey: `${hosting.identity}:${issue.id}`,
133
+ attempt: 1,
134
+ issue,
135
+ now,
136
+ branchTemplate: project.branchTemplate,
137
+ });
138
+ await this.store.insertRun(run);
139
+ }
140
+ }
141
+ async tick() {
142
+ if (this.tickBusy || this.stopping)
143
+ return;
144
+ this.tickBusy = true;
145
+ try {
146
+ await this.processCommands();
147
+ this.pollDueProjects();
148
+ await this.dispatchRuns();
149
+ }
150
+ finally {
151
+ this.tickBusy = false;
152
+ }
153
+ }
154
+ async processCommands() {
155
+ for (const request of (await this.store.commands()).filter((c) => c.status === "pending")) {
156
+ try {
157
+ if (request.kind === "pause")
158
+ await this.pause(request.target);
159
+ else if (request.kind === "resume")
160
+ await this.resume(request.target);
161
+ else if (request.kind === "stop")
162
+ await this.stop(request.target);
163
+ else if (request.kind === "retry")
164
+ await this.retry(request.target, request.id);
165
+ else
166
+ throw new Error("Unknown command");
167
+ await this.store.finishCommand(request.id);
168
+ }
169
+ catch (error) {
170
+ await this.store.finishCommand(request.id, this.redact(String(error)));
171
+ }
172
+ }
173
+ }
174
+ pollDueProjects() {
175
+ if (this.stopping)
176
+ return;
177
+ for (const project of this.config.projects) {
178
+ if (!this.polling.has(project.id) &&
179
+ Date.now() - (this.lastPoll.get(project.id) ?? 0) >=
180
+ project.pollIntervalMs) {
181
+ this.lastPoll.set(project.id, Date.now());
182
+ void this.poll(project.id).catch((error) => runtimeLogger(this.redact).error(String(error)));
183
+ }
184
+ }
185
+ }
186
+ async dispatchRuns() {
187
+ if (this.stopping)
188
+ return;
189
+ const runs = await this.store.runs();
190
+ for (const project of this.config.projects) {
191
+ const state = await this.store.project(project.id);
192
+ if (this.stopping)
193
+ return;
194
+ if (state.paused || state.blocked)
195
+ continue;
196
+ const run = runs.find((r) => r.projectId === project.id &&
197
+ (r.outcome === "queued" || r.outcome === "running"));
198
+ if (!run || this.active.has(run.id))
199
+ continue;
200
+ const handle = await DBOS.startWorkflow(this.workflow, {
201
+ workflowID: run.id,
202
+ queueName: this.queue(project.id),
203
+ })(run.id);
204
+ const result = handle
205
+ .getResult()
206
+ .catch(async (error) => {
207
+ await this.recordWorkflowFailure(run, error);
208
+ })
209
+ .finally(() => this.active.delete(run.id));
210
+ this.active.set(run.id, result);
211
+ }
212
+ }
213
+ async recordWorkflowFailure(run, error) {
214
+ const message = this.redact(String(error));
215
+ await this.store.emit(run.id, "workflow-error", { error: message });
216
+ const current = await this.store.run(run.id);
217
+ if (["queued", "running"].includes(current.outcome)) {
218
+ await this.store.patchRun(run.id, {
219
+ outcome: "blocked",
220
+ error: message,
221
+ });
222
+ await this.store.setProject(run.projectId, {
223
+ blocked: `DBOS execution failed for ${run.id}; inspect recovery evidence`,
224
+ });
225
+ }
226
+ }
227
+ async execute(runId) {
228
+ const controller = new AbortController();
229
+ if (this.stopping)
230
+ controller.abort();
231
+ this.controllers.set(runId, controller);
232
+ try {
233
+ await this.executeOwned(runId, controller);
234
+ }
235
+ finally {
236
+ this.controllers.delete(runId);
237
+ }
238
+ }
239
+ async executeOwned(runId, controller) {
240
+ const run = await DBOS.runStep(() => this.store.run(runId), {
241
+ name: "load-run",
242
+ });
243
+ const project = this.config.projects.find((p) => p.id === run.projectId);
244
+ if (!project)
245
+ throw new Error("Project removed from configuration");
246
+ const workspace = this.options.workspace ?? new ExistingCheckout();
247
+ const operations = new Operations(runId, {
248
+ store: this.store,
249
+ project,
250
+ workspace,
251
+ hosting: this.hosting.get(project.id),
252
+ agents: this.options.agents,
253
+ artifacts: resolve(this.config.stateDirectory),
254
+ signal: controller.signal,
255
+ redact: this.redact,
256
+ });
257
+ try {
258
+ while (true) {
259
+ const state = await DBOS.runStep(() => this.store.project(project.id), {
260
+ name: "start-gate",
261
+ });
262
+ if (state.blocked)
263
+ throw new BlockedError(state.blocked);
264
+ if (!state.paused)
265
+ break;
266
+ controller.signal.throwIfAborted();
267
+ await DBOS.sleepms(200);
268
+ }
269
+ controller.signal.throwIfAborted();
270
+ await (this.options.workflow ?? defaultWorkflow)(operations);
271
+ await DBOS.runStep(async () => {
272
+ const completed = await this.store.run(runId);
273
+ if (["queued", "running"].includes(completed.outcome))
274
+ throw new BlockedError("Custom workflow returned without a terminal outcome");
275
+ }, { name: "terminal-check" });
276
+ }
277
+ catch (error) {
278
+ await DBOS.runStep(async () => {
279
+ let unsafe = false;
280
+ try {
281
+ await workspace.check(project);
282
+ }
283
+ catch {
284
+ unsafe = true;
285
+ }
286
+ const outcome = controller.signal.aborted
287
+ ? "cancelled"
288
+ : isBlockedError(error)
289
+ ? "blocked"
290
+ : "failed";
291
+ await this.store.patchRun(runId, {
292
+ outcome,
293
+ error: this.redact(String(error)),
294
+ });
295
+ if (unsafe || isBlockedError(error))
296
+ await this.store.setProject(project.id, {
297
+ blocked: `Run ${runId} requires recovery: ${this.redact(String(error))}`,
298
+ });
299
+ }, { name: "record-failure", retriesAllowed: false });
300
+ }
301
+ finally {
302
+ this.controllers.delete(runId);
303
+ }
304
+ }
305
+ async pause(projectId) {
306
+ await this.store.project(projectId);
307
+ await this.store.setProject(projectId, { paused: true });
308
+ }
309
+ async resume(projectId) {
310
+ const state = await this.store.project(projectId);
311
+ if (state.blocked)
312
+ throw new Error(state.blocked);
313
+ await this.store.setProject(projectId, { paused: false });
314
+ }
315
+ async stop(runId) {
316
+ const run = await this.store.run(runId);
317
+ const controller = this.controllers.get(runId);
318
+ if (controller) {
319
+ controller.abort();
320
+ while (this.controllers.has(runId))
321
+ await new Promise((resolve) => setTimeout(resolve, 20));
322
+ return;
323
+ }
324
+ if (run.outcome === "queued") {
325
+ await DBOS.cancelWorkflow(runId);
326
+ await this.store.patchRun(runId, { outcome: "cancelled" });
327
+ return;
328
+ }
329
+ if (run.outcome === "running")
330
+ throw new BlockedError("No local process ownership for this running workflow");
331
+ }
332
+ async retry(runId, commandId) {
333
+ return this.store.admitRetry(runId, {
334
+ commandId,
335
+ checkSafety: async (previous) => {
336
+ if (this.controllers.has(runId))
337
+ throw new Error("Work has not stopped");
338
+ const project = this.config.projects.find((p) => p.id === previous.projectId);
339
+ if (!project)
340
+ throw new Error("Project removed from configuration");
341
+ await assertProcessesStopped(resolve(this.config.stateDirectory, runId));
342
+ await (this.options.workspace ?? new ExistingCheckout()).check(project);
343
+ return {
344
+ checkout: project.checkout,
345
+ branchTemplate: project.branchTemplate,
346
+ };
347
+ },
348
+ });
349
+ }
350
+ async shutdown() {
351
+ this.stopping = true;
352
+ if (this.timer)
353
+ clearInterval(this.timer);
354
+ for (const controller of this.controllers.values())
355
+ controller.abort();
356
+ while (this.controllers.size || this.tickBusy)
357
+ await new Promise((resolve) => setTimeout(resolve, 20));
358
+ await Promise.all(this.active.values());
359
+ await Promise.allSettled(this.polling.values());
360
+ if (this.ownsRuntime) {
361
+ await DBOS.shutdown({
362
+ deregister: true,
363
+ workflowCompletionTimeoutMS: 5000,
364
+ });
365
+ this.ownsRuntime = false;
366
+ }
367
+ await this.ownership.release();
368
+ await this.store.close();
369
+ }
370
+ }
@@ -0,0 +1,8 @@
1
+ export declare function processExists(pid: number, group?: boolean): boolean;
2
+ export declare function assertProcessesStopped(directory: string): Promise<void>;
3
+ /** Filesystem lease prevents different databases/configurations owning one checkout. */
4
+ export declare class CheckoutOwnership {
5
+ private readonly paths;
6
+ acquire(checkout: string, artifacts: string): Promise<void>;
7
+ release(): Promise<void>;
8
+ }
@@ -0,0 +1,84 @@
1
+ import { open, readdir, readFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { BlockedError } from "../domain.js";
4
+ import { command } from "./process.js";
5
+ export function processExists(pid, group = false) {
6
+ try {
7
+ process.kill(group && process.platform !== "win32" ? -pid : pid, 0);
8
+ return true;
9
+ }
10
+ catch (error) {
11
+ return error.code === "EPERM";
12
+ }
13
+ }
14
+ export async function assertProcessesStopped(directory) {
15
+ let entries;
16
+ try {
17
+ entries = await readdir(directory, { withFileTypes: true });
18
+ }
19
+ catch (error) {
20
+ if (error.code === "ENOENT")
21
+ return;
22
+ throw error;
23
+ }
24
+ for (const entry of entries) {
25
+ const path = join(directory, entry.name);
26
+ if (entry.isDirectory())
27
+ await assertProcessesStopped(path);
28
+ else if (entry.name.endsWith(".process.json")) {
29
+ const record = JSON.parse(await readFile(path, "utf8"));
30
+ if (processExists(record.pid, true))
31
+ throw new BlockedError(`Previous process group ${record.pid} is still alive; inspect ${path} before recovery`);
32
+ }
33
+ }
34
+ }
35
+ /** Filesystem lease prevents different databases/configurations owning one checkout. */
36
+ export class CheckoutOwnership {
37
+ paths = [];
38
+ async acquire(checkout, artifacts) {
39
+ const gitDirectory = (await command("git", ["rev-parse", "--absolute-git-dir"], {
40
+ cwd: checkout,
41
+ })).stdout.trim();
42
+ const path = join(gitDirectory, "agent-workflows-owner.json");
43
+ let guard;
44
+ try {
45
+ guard = await open(path + ".acquiring", "wx", 0o600);
46
+ }
47
+ catch (error) {
48
+ if (error.code === "EEXIST")
49
+ throw new BlockedError(`Checkout acquisition is locked: ${path}.acquiring; inspect its owner before removing a stale guard`);
50
+ throw error;
51
+ }
52
+ try {
53
+ await guard.writeFile(JSON.stringify({ pid: process.pid }));
54
+ await assertProcessesStopped(join(gitDirectory, "agent-workflows-processes"));
55
+ try {
56
+ const previous = JSON.parse(await readFile(path, "utf8"));
57
+ if (processExists(previous.pid))
58
+ throw new BlockedError(`Checkout owned by process ${previous.pid}: ${checkout}`);
59
+ await assertProcessesStopped(previous.artifacts);
60
+ await unlink(path);
61
+ }
62
+ catch (error) {
63
+ if (error.code !== "ENOENT")
64
+ throw error;
65
+ }
66
+ const file = await open(path, "wx", 0o600);
67
+ try {
68
+ await file.writeFile(JSON.stringify({ pid: process.pid, artifacts }));
69
+ this.paths.push(path);
70
+ }
71
+ finally {
72
+ await file.close();
73
+ }
74
+ }
75
+ finally {
76
+ await guard.close();
77
+ await unlink(path + ".acquiring");
78
+ }
79
+ }
80
+ async release() {
81
+ for (const path of this.paths.splice(0))
82
+ await unlink(path);
83
+ }
84
+ }
@@ -0,0 +1,18 @@
1
+ export interface CommandOptions {
2
+ cwd: string;
3
+ processFile?: string;
4
+ signal?: AbortSignal;
5
+ timeoutMs?: number;
6
+ killGraceMs?: number;
7
+ env?: NodeJS.ProcessEnv;
8
+ allowFailure?: boolean;
9
+ captureOutput?: boolean;
10
+ onOutput?: (chunk: string) => void;
11
+ }
12
+ export interface CommandResult {
13
+ stdout: string;
14
+ stderr: string;
15
+ exitCode: number;
16
+ }
17
+ /** Resolves only after the child closes. Cancellation terminates its process group. */
18
+ export declare function command(executable: string, args: string[], options: CommandOptions): Promise<CommandResult>;
@@ -0,0 +1,98 @@
1
+ import { spawn } from "node:child_process";
2
+ import { unlinkSync, writeFileSync } from "node:fs";
3
+ /** Resolves only after the child closes. Cancellation terminates its process group. */
4
+ export function command(executable, args, options) {
5
+ options.signal?.throwIfAborted();
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(executable, args, {
8
+ cwd: options.cwd,
9
+ env: options.env ?? process.env,
10
+ detached: process.platform !== "win32",
11
+ stdio: ["ignore", "pipe", "pipe"],
12
+ });
13
+ let stdout = "", stderr = "", cancelled = false, killTimer;
14
+ let failure;
15
+ const kill = (signal) => {
16
+ if (!child.pid)
17
+ return;
18
+ try {
19
+ if (process.platform === "win32")
20
+ child.kill(signal);
21
+ else
22
+ process.kill(-child.pid, signal);
23
+ }
24
+ catch (error) {
25
+ if (error.code !== "ESRCH")
26
+ failure = error;
27
+ }
28
+ };
29
+ const stop = () => {
30
+ cancelled = true;
31
+ kill("SIGTERM");
32
+ killTimer ??= setTimeout(() => kill("SIGKILL"), options.killGraceMs ?? 2000);
33
+ };
34
+ try {
35
+ if (options.processFile && child.pid)
36
+ writeFileSync(options.processFile, JSON.stringify({ pid: child.pid }), {
37
+ mode: 0o600,
38
+ });
39
+ }
40
+ catch (error) {
41
+ failure = error;
42
+ stop();
43
+ }
44
+ const timeout = setTimeout(stop, options.timeoutMs ?? 300_000);
45
+ options.signal?.addEventListener("abort", stop, { once: true });
46
+ const collect = (target, chunk) => {
47
+ const value = chunk.toString();
48
+ try {
49
+ options.onOutput?.(value);
50
+ }
51
+ catch (error) {
52
+ failure = error;
53
+ stop();
54
+ }
55
+ if (options.captureOutput !== false &&
56
+ stdout.length + stderr.length <= 32 * 1024 * 1024) {
57
+ if (target === "stdout")
58
+ stdout += value;
59
+ else
60
+ stderr += value;
61
+ if (stdout.length + stderr.length > 32 * 1024 * 1024)
62
+ stop();
63
+ }
64
+ };
65
+ child.stdout.on("data", (chunk) => collect("stdout", chunk));
66
+ child.stderr.on("data", (chunk) => collect("stderr", chunk));
67
+ const cleanup = () => {
68
+ clearTimeout(timeout);
69
+ if (killTimer)
70
+ clearTimeout(killTimer);
71
+ options.signal?.removeEventListener("abort", stop);
72
+ };
73
+ child.on("error", (error) => {
74
+ failure = error;
75
+ });
76
+ child.on("close", (code) => {
77
+ // Kill any descendants left behind by a command that exited before them.
78
+ kill("SIGKILL");
79
+ cleanup();
80
+ if (options.processFile) {
81
+ try {
82
+ unlinkSync(options.processFile);
83
+ }
84
+ catch (error) {
85
+ if (error.code !== "ENOENT")
86
+ return reject(error);
87
+ }
88
+ }
89
+ if (failure)
90
+ return reject(failure);
91
+ if (cancelled)
92
+ return reject(new Error("Command cancelled or timed out; process group terminated"));
93
+ if (code !== 0 && !options.allowFailure)
94
+ return reject(new Error(`${executable} failed (${code}): ${stderr.trim()}`));
95
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
96
+ });
97
+ });
98
+ }
@@ -0,0 +1,8 @@
1
+ export declare function createRedactor(extra?: string[]): (text: string) => string;
2
+ export declare function redactValue(value: unknown, redact: (text: string) => string): unknown;
3
+ export declare function runtimeLogger(redact: (text: string) => string): {
4
+ info: (entry: unknown) => void;
5
+ debug: (entry: unknown) => void;
6
+ warn: (entry: unknown) => void;
7
+ error: (entry: unknown) => void;
8
+ };
@@ -0,0 +1,33 @@
1
+ import pino from "pino";
2
+ export function createRedactor(extra = []) {
3
+ const secrets = [
4
+ ...extra,
5
+ ...Object.entries(process.env)
6
+ .filter(([name]) => /TOKEN|PASSWORD|SECRET|API_KEY|DATABASE_URL/.test(name))
7
+ .map(([, value]) => value),
8
+ ]
9
+ .filter((value) => !!value && value.length >= 4)
10
+ .sort((a, b) => b.length - a.length);
11
+ return (text) => secrets.reduce((result, secret) => result.split(secret).join("[REDACTED]"), text);
12
+ }
13
+ export function redactValue(value, redact) {
14
+ if (typeof value === "string")
15
+ return redact(value);
16
+ if (Array.isArray(value))
17
+ return value.map((item) => redactValue(item, redact));
18
+ if (value && typeof value === "object")
19
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
20
+ key,
21
+ redactValue(item, redact),
22
+ ]));
23
+ return value;
24
+ }
25
+ export function runtimeLogger(redact) {
26
+ const logger = pino({ level: "warn" }, pino.destination(2));
27
+ return {
28
+ info: (entry) => logger.info(redact(String(entry))),
29
+ debug: (entry) => logger.debug(redact(String(entry))),
30
+ warn: (entry) => logger.warn(redact(String(entry))),
31
+ error: (entry) => logger.error(redact(String(entry))),
32
+ };
33
+ }