@mingchuno/agent-workflows 0.4.0 → 0.5.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.
@@ -2,6 +2,11 @@ import type { Project } from "./config.js";
2
2
  import { type HostingAdapter, type RunRecord, type Workspace } from "./domain.js";
3
3
  import type { Store } from "./store.js";
4
4
  export declare const publicationSteps: readonly string[];
5
+ interface WorkflowStep {
6
+ functionID: number;
7
+ name?: string;
8
+ error?: unknown;
9
+ }
5
10
  export declare function recoveryUnavailable(run: RunRecord): string | undefined;
6
11
  /** Hash execution inputs without reading credential environment values. */
7
12
  export declare function executionFingerprint(project: Project, workflowVersion: string, signal?: AbortSignal): Promise<string>;
@@ -14,6 +19,19 @@ interface RecoveryDependencies {
14
19
  workflowVersion: string;
15
20
  signal?: AbortSignal;
16
21
  }
17
- /** Read-only checks shared by admission and the first non-replayed operation. */
18
- export declare function verifyPublicationRecovery(run: RunRecord, dependencies: RecoveryDependencies): Promise<void>;
22
+ /** Verify checkpoint history and live state before persisting recovery intent. */
23
+ export declare function verifyPublicationRecoveryAdmission(run: RunRecord, checkpoints: {
24
+ status: {
25
+ status: string;
26
+ applicationVersion?: string;
27
+ } | null | undefined;
28
+ steps: readonly WorkflowStep[] | undefined;
29
+ applicationVersion: string;
30
+ }, dependencies: RecoveryDependencies): Promise<string[]>;
31
+ interface RecoveryStartDependencies extends RecoveryDependencies {
32
+ store: Pick<Store, "invocations" | "project" | "patchRun">;
33
+ signal: AbortSignal;
34
+ }
35
+ /** Revalidate a recovered Execution at its first non-replayed step. */
36
+ export declare function resumePublicationExecution(run: RunRecord, workflowId: string, stepId: number, dependencies: RecoveryStartDependencies): Promise<void>;
19
37
  export {};
@@ -6,11 +6,38 @@ import { verifyEvidence } from "./evidence.js";
6
6
  import { projectPrompts } from "./prompts.js";
7
7
  import { assertProcessesStopped } from "./runtime/ownership.js";
8
8
  import { command } from "./runtime/process.js";
9
+ const recoveryGatePollIntervalMs = 100;
9
10
  export const publicationSteps = [
10
11
  "push",
11
12
  "change-request",
12
13
  "review-publication",
13
14
  ];
15
+ /** Prove that a fork will replay only completed publication checkpoints. */
16
+ function completedPublicationCheckpoints(run, status, steps, applicationVersion) {
17
+ if (!status || !["SUCCESS", "ERROR"].includes(status.status))
18
+ throw new Error("Source execution has not finished");
19
+ if (status.applicationVersion !== applicationVersion)
20
+ throw new Error("Workflow version changed; use retry");
21
+ const failed = steps?.find((step) => step.functionID === run.executions?.at(-1)?.failedStep);
22
+ if (!failed?.error || failed.name !== run.phase)
23
+ throw new Error("Failed publication checkpoint is unavailable");
24
+ const prefix = steps.filter((step) => step.functionID < failed.functionID);
25
+ if (prefix.length !== failed.functionID ||
26
+ prefix.some((step, index) => step.error || step.functionID !== index) ||
27
+ !prefix.some((step) => step.name === "commit"))
28
+ throw new Error("Completed publication checkpoints are unavailable");
29
+ return prefix.map((step) => step.name);
30
+ }
31
+ /** Check the identity of the first step that DBOS did not replay. */
32
+ function verifyPublicationExecutionStart(run, workflowId, stepId) {
33
+ const execution = run.executions?.at(-1);
34
+ if (!execution?.recoveryOf)
35
+ return;
36
+ if (execution.id !== workflowId)
37
+ throw new BlockedError("Execution has been superseded");
38
+ if (stepId < execution.startStep)
39
+ throw new BlockedError("A reused checkpoint is missing; recovery refused");
40
+ }
14
41
  export function recoveryUnavailable(run) {
15
42
  if (run.outcome !== "failed")
16
43
  return "Only failed publication runs can be recovered";
@@ -47,8 +74,29 @@ export async function executionFingerprint(project, workflowVersion, signal) {
47
74
  }))
48
75
  .digest("hex");
49
76
  }
77
+ /** Verify checkpoint history and live state before persisting recovery intent. */
78
+ export async function verifyPublicationRecoveryAdmission(run, checkpoints, dependencies) {
79
+ const completedSteps = completedPublicationCheckpoints(run, checkpoints.status, checkpoints.steps, checkpoints.applicationVersion);
80
+ await verifyPublicationRecovery(run, dependencies);
81
+ return completedSteps;
82
+ }
83
+ /** Revalidate a recovered Execution at its first non-replayed step. */
84
+ export async function resumePublicationExecution(run, workflowId, stepId, dependencies) {
85
+ verifyPublicationExecutionStart(run, workflowId, stepId);
86
+ while (true) {
87
+ dependencies.signal.throwIfAborted();
88
+ const state = await dependencies.store.project(dependencies.project.id);
89
+ if (state.blocked)
90
+ throw new BlockedError(state.blocked);
91
+ if (!state.paused)
92
+ break;
93
+ await new Promise((resolve) => setTimeout(resolve, recoveryGatePollIntervalMs));
94
+ }
95
+ await dependencies.store.patchRun(run.id, { outcome: "running" });
96
+ await verifyPublicationRecovery(run, dependencies);
97
+ }
50
98
  /** Read-only checks shared by admission and the first non-replayed operation. */
51
- export async function verifyPublicationRecovery(run, dependencies) {
99
+ async function verifyPublicationRecovery(run, dependencies) {
52
100
  const { project, workspace, hosting, store, stateDirectory, workflowVersion, signal, } = dependencies;
53
101
  if (run.checkout !== project.checkout)
54
102
  throw new BlockedError("Configured checkout differs from the recorded run checkout");
@@ -43,12 +43,16 @@ export declare class Runner {
43
43
  private recordWorkflowFailure;
44
44
  private execute;
45
45
  private executeOwned;
46
+ private checkExecutionStart;
47
+ private recordExecutionFailure;
46
48
  pause(projectId: string): Promise<void>;
47
49
  private initializeExecution;
48
50
  resume(projectId: string): Promise<void>;
49
51
  stop(runId: string): Promise<void>;
50
- retry(runId: string, commandId?: string): Promise<string>;
52
+ retry(runId: string, commandId?: string, options?: {
53
+ refreshIssue?: boolean;
54
+ }): Promise<string>;
51
55
  recover(runId: string, commandId?: string): Promise<string>;
52
- private checkRecoveryState;
56
+ private projectForRun;
53
57
  shutdown(): Promise<void>;
54
58
  }
@@ -8,11 +8,12 @@ import { BlockedError, isBlockedError, } from "./domain.js";
8
8
  import { assertEvidenceDirectory } from "./evidence.js";
9
9
  import { defaultWorkflow, Operations } from "./operations.js";
10
10
  import { projectPrompts } from "./prompts.js";
11
- import { executionFingerprint, verifyPublicationRecovery } from "./recovery.js";
11
+ import { executionFingerprint, resumePublicationExecution, verifyPublicationRecoveryAdmission, } from "./recovery.js";
12
12
  import { createQueuedRun } from "./run-record.js";
13
13
  import { assertProcessesStopped, CheckoutOwnership, } from "./runtime/ownership.js";
14
14
  import { createRedactor, runtimeLogger } from "./runtime/redaction.js";
15
15
  import { Store } from "./store.js";
16
+ import { selectValidation } from "./validation-selection.js";
16
17
  import { ExistingCheckout } from "./workspace.js";
17
18
  const runnerPollIntervalMs = 100;
18
19
  const shutdownPollIntervalMs = 20;
@@ -180,6 +181,8 @@ export class Runner {
180
181
  await this.stop(request.target);
181
182
  else if (request.kind === "retry")
182
183
  await this.retry(request.target, request.id);
184
+ else if (request.kind === "retry-refresh")
185
+ await this.retry(request.target, request.id, { refreshIssue: true });
183
186
  else if (request.kind === "recover")
184
187
  await this.recover(request.target, request.id);
185
188
  else
@@ -281,9 +284,7 @@ export class Runner {
281
284
  const run = await DBOS.runStep(() => this.initializeExecution(runId), {
282
285
  name: "load-run",
283
286
  });
284
- const project = this.config.projects.find((p) => p.id === run.projectId);
285
- if (!project)
286
- throw new Error("Project removed from configuration");
287
+ const project = this.projectForRun(run);
287
288
  const workspace = this.options.workspace ?? new ExistingCheckout();
288
289
  let recoveryChecked = false;
289
290
  const operations = new Operations(runId, {
@@ -300,30 +301,7 @@ export class Runner {
300
301
  beforeStep: async () => {
301
302
  if (recoveryChecked)
302
303
  return;
303
- const current = await this.store.run(runId);
304
- const execution = current.executions?.at(-1);
305
- if (!execution?.recoveryOf) {
306
- await this.store.patchRun(runId, { outcome: "running" });
307
- recoveryChecked = true;
308
- return;
309
- }
310
- if (execution.id !== DBOS.workflowID)
311
- throw new BlockedError("Execution has been superseded");
312
- if (DBOS.stepID < execution.startStep)
313
- throw new BlockedError("A reused checkpoint is missing; recovery refused");
314
- // This runs inside the first non-replayed step, so copied start-gate
315
- // checkpoints cannot bypass today's pause or checkout checks.
316
- while (true) {
317
- controller.signal.throwIfAborted();
318
- const state = await this.store.project(project.id);
319
- if (state.blocked)
320
- throw new BlockedError(state.blocked);
321
- if (!state.paused)
322
- break;
323
- await new Promise((resolve) => setTimeout(resolve, runnerPollIntervalMs));
324
- }
325
- await this.store.patchRun(runId, { outcome: "running" });
326
- await this.checkRecoveryState(current, project, controller.signal);
304
+ await this.checkExecutionStart(runId, project, controller.signal);
327
305
  recoveryChecked = true;
328
306
  },
329
307
  });
@@ -348,31 +326,50 @@ export class Runner {
348
326
  }, { name: "terminal-check" });
349
327
  }
350
328
  catch (error) {
351
- await DBOS.runStep(async () => {
352
- let unsafe = false;
353
- try {
354
- await workspace.check(project);
355
- }
356
- catch {
357
- unsafe = true;
358
- }
359
- const outcome = controller.signal.aborted
360
- ? "cancelled"
361
- : isBlockedError(error)
362
- ? "blocked"
363
- : "failed";
364
- await this.store.patchRun(runId, {
365
- outcome,
366
- error: this.redact(String(error)),
367
- });
368
- if (unsafe || isBlockedError(error))
369
- await this.store.blockProject(project.id, `Run ${runId} requires recovery: ${this.redact(String(error))}`);
370
- }, { name: "record-failure", retriesAllowed: false });
329
+ await DBOS.runStep(() => this.recordExecutionFailure(runId, project, workspace, controller, error), { name: "record-failure", retriesAllowed: false });
371
330
  }
372
331
  finally {
373
332
  this.controllers.delete(runId);
374
333
  }
375
334
  }
335
+ async checkExecutionStart(runId, project, signal) {
336
+ const current = await this.store.run(runId);
337
+ const execution = current.executions?.at(-1);
338
+ if (!execution?.recoveryOf) {
339
+ await this.store.patchRun(runId, { outcome: "running" });
340
+ return;
341
+ }
342
+ // The first non-replayed step must check today's pause and checkout state.
343
+ await resumePublicationExecution(current, DBOS.workflowID, DBOS.stepID, {
344
+ project,
345
+ signal,
346
+ store: this.store,
347
+ workspace: this.options.workspace ?? new ExistingCheckout(),
348
+ hosting: this.hosting.get(project.id),
349
+ stateDirectory: this.config.stateDirectory,
350
+ workflowVersion: this.options.workflowVersion ?? "phase1-v2",
351
+ });
352
+ }
353
+ async recordExecutionFailure(runId, project, workspace, controller, error) {
354
+ let unsafe = false;
355
+ try {
356
+ await workspace.check(project);
357
+ }
358
+ catch {
359
+ unsafe = true;
360
+ }
361
+ const outcome = controller.signal.aborted
362
+ ? "cancelled"
363
+ : isBlockedError(error)
364
+ ? "blocked"
365
+ : "failed";
366
+ await this.store.patchRun(runId, {
367
+ outcome,
368
+ error: this.redact(String(error)),
369
+ });
370
+ if (unsafe || isBlockedError(error))
371
+ await this.store.blockProject(project.id, `Run ${runId} requires recovery: ${this.redact(String(error))}`);
372
+ }
376
373
  async pause(projectId) {
377
374
  await this.store.project(projectId);
378
375
  await this.store.setProject(projectId, { paused: true });
@@ -381,9 +378,7 @@ export class Runner {
381
378
  const run = await this.store.run(runId);
382
379
  if (run.executions?.length)
383
380
  return run;
384
- const project = this.config.projects.find((item) => item.id === run.projectId);
385
- if (!project)
386
- throw new Error("Project removed from configuration");
381
+ this.projectForRun(run);
387
382
  return this.store.patchRun(runId, {
388
383
  executions: [
389
384
  {
@@ -421,20 +416,33 @@ export class Runner {
421
416
  if (run.outcome === "running")
422
417
  throw new BlockedError("No local process ownership for this running workflow");
423
418
  }
424
- async retry(runId, commandId) {
419
+ async retry(runId, commandId, options = {}) {
425
420
  return this.store.admitRetry(runId, {
426
421
  commandId,
427
422
  checkSafety: async (previous) => {
428
423
  if (this.controllers.has(runId))
429
424
  throw new Error("Work has not stopped");
430
- const project = this.config.projects.find((p) => p.id === previous.projectId);
431
- if (!project)
432
- throw new Error("Project removed from configuration");
425
+ const project = this.projectForRun(previous);
433
426
  await assertProcessesStopped(resolve(this.config.stateDirectory, runId));
434
427
  await (this.options.workspace ?? new ExistingCheckout()).check(project);
428
+ let issue;
429
+ if (options.refreshIssue) {
430
+ const hosting = this.hosting.get(project.id) ?? this.options.hosting(project);
431
+ const current = await hosting.getIssue(previous.issue.number);
432
+ if (current.id !== previous.issue.id ||
433
+ current.number !== previous.issue.number ||
434
+ current.url !== previous.issue.url)
435
+ throw new Error("Refreshed issue identity differs from the run");
436
+ if (!current.open ||
437
+ !project.labels.every((label) => current.labels.includes(label)))
438
+ throw new Error("Refreshed issue is closed or missing required labels");
439
+ selectValidation(current.body, project);
440
+ issue = current;
441
+ }
435
442
  return {
436
443
  checkout: project.checkout,
437
444
  branchTemplate: project.branchTemplate,
445
+ issue,
438
446
  };
439
447
  },
440
448
  });
@@ -449,40 +457,30 @@ export class Runner {
449
457
  throw new Error("Publication recovery currently supports the default workflow only");
450
458
  if (this.controllers.has(runId))
451
459
  throw new Error("Work has not stopped");
452
- const project = this.config.projects.find((item) => item.id === run.projectId);
453
- if (!project)
454
- throw new Error("Project removed from configuration");
460
+ const project = this.projectForRun(run);
455
461
  const execution = run.executions.at(-1);
456
462
  const status = await DBOS.getWorkflowStatus(execution.id);
457
- if (!status || !["SUCCESS", "ERROR"].includes(status.status))
458
- throw new Error("Source execution has not finished");
459
- if (status.applicationVersion !==
460
- `${this.config.id}-${this.options.workflowVersion ?? "phase1-v2"}`)
461
- throw new Error("Workflow version changed; use retry");
462
463
  const steps = await DBOS.listWorkflowSteps(execution.id);
463
- const failed = steps?.find((step) => step.functionID === execution.failedStep);
464
- if (!failed?.error || failed.name !== run.phase)
465
- throw new Error("Failed publication checkpoint is unavailable");
466
- const prefix = steps.filter((step) => step.functionID < failed.functionID);
467
- if (prefix.length !== failed.functionID ||
468
- prefix.some((step, index) => step.error || step.functionID !== index) ||
469
- !prefix.some((step) => step.name === "commit"))
470
- throw new Error("Completed publication checkpoints are unavailable");
471
- await this.checkRecoveryState(run, project);
472
- return prefix.map((step) => step.name);
464
+ return verifyPublicationRecoveryAdmission(run, {
465
+ status,
466
+ steps,
467
+ applicationVersion: `${this.config.id}-${this.options.workflowVersion ?? "phase1-v2"}`,
468
+ }, {
469
+ project,
470
+ store: this.store,
471
+ workspace: this.options.workspace ?? new ExistingCheckout(),
472
+ hosting: this.hosting.get(project.id),
473
+ stateDirectory: this.config.stateDirectory,
474
+ workflowVersion: this.options.workflowVersion ?? "phase1-v2",
475
+ });
473
476
  },
474
477
  });
475
478
  }
476
- async checkRecoveryState(run, project, signal) {
477
- await verifyPublicationRecovery(run, {
478
- project,
479
- signal,
480
- store: this.store,
481
- workspace: this.options.workspace ?? new ExistingCheckout(),
482
- hosting: this.hosting.get(project.id),
483
- stateDirectory: this.config.stateDirectory,
484
- workflowVersion: this.options.workflowVersion ?? "phase1-v2",
485
- });
479
+ projectForRun(run) {
480
+ const project = this.config.projects.find((item) => item.id === run.projectId);
481
+ if (!project)
482
+ throw new Error("Project removed from configuration");
483
+ return project;
486
484
  }
487
485
  async shutdown() {
488
486
  this.stopping = true;
@@ -10,6 +10,7 @@ export interface CommandOptions {
10
10
  captureOutput?: boolean;
11
11
  strictUtf8?: boolean;
12
12
  onOutput?: (chunk: string) => void;
13
+ onStderr?: (chunk: string) => void;
13
14
  }
14
15
  export interface CommandResult {
15
16
  stdout: string;
@@ -64,8 +64,12 @@ export function command(executable, args, options) {
64
64
  const value = decoders[target].decode(chunk, {
65
65
  stream: chunk !== undefined,
66
66
  });
67
- if (value)
68
- options.onOutput?.(value);
67
+ if (value) {
68
+ if (target === "stderr" && options.onStderr)
69
+ options.onStderr(value);
70
+ else
71
+ options.onOutput?.(value);
72
+ }
69
73
  if (options.captureOutput !== false) {
70
74
  if (target === "stdout")
71
75
  stdout += value;
@@ -1,5 +1,5 @@
1
1
  import { Pool } from "pg";
2
- import type { RunRecord } from "./domain.js";
2
+ import type { Issue, RunRecord } from "./domain.js";
3
3
  export interface EventRecord {
4
4
  sequence: number;
5
5
  runId: string | null;
@@ -41,6 +41,7 @@ interface RetryAdmission {
41
41
  checkSafety: (previous: RunRecord) => Promise<{
42
42
  checkout: string;
43
43
  branchTemplate: string;
44
+ issue?: Issue;
44
45
  }>;
45
46
  }
46
47
  interface RecoveryAdmission {
@@ -88,12 +89,13 @@ export declare class Store {
88
89
  saveInvocation(record: InvocationRecord): Promise<void>;
89
90
  invocations(runId: string): Promise<InvocationRecord[]>;
90
91
  emit(runId: string | null, kind: string, payload: unknown): Promise<void>;
92
+ private event;
91
93
  events(after?: number, runId?: string): Promise<EventRecord[]>;
92
94
  subscribe(listener: (event: EventRecord) => void, options?: {
93
95
  after?: number;
94
96
  intervalMs?: number;
95
97
  }): () => void;
96
- request(kind: "pause" | "resume" | "stop" | "retry" | "recover", target: string): Promise<string>;
98
+ request(kind: "pause" | "resume" | "stop" | "retry" | "retry-refresh" | "recover", target: string): Promise<string>;
97
99
  commands(): Promise<Array<{
98
100
  id: string;
99
101
  kind: string;
package/dist/src/store.js CHANGED
@@ -88,41 +88,51 @@ export class Store {
88
88
  return state;
89
89
  }
90
90
  async setProject(id, change) {
91
- const { projects } = tables;
92
- if (change.paused !== undefined || change.blocked !== undefined) {
93
- await this.db
94
- .update(projects)
95
- .set(change)
96
- .where(and(eq(projects.scope, this.scope), eq(projects.id, id)));
97
- }
98
- await this.emit(null, "project", { id, ...change });
91
+ await this.db.transaction(async (tx) => {
92
+ const { projects } = tables;
93
+ if (change.paused !== undefined || change.blocked !== undefined) {
94
+ await tx
95
+ .update(projects)
96
+ .set(change)
97
+ .where(and(eq(projects.scope, this.scope), eq(projects.id, id)));
98
+ }
99
+ await tx
100
+ .insert(tables.events)
101
+ .values(this.event(null, "project", { id, ...change }));
102
+ });
99
103
  }
100
104
  async insertRun(run) {
101
- const { runs } = tables;
102
- const inserted = await this.db
103
- .insert(runs)
104
- .values({
105
- scope: this.scope,
106
- id: run.id,
107
- taskKey: run.taskKey,
108
- attempt: run.attempt,
109
- record: redactValue(run, this.redact),
110
- })
111
- .onConflictDoNothing()
112
- .returning({ id: runs.id });
113
- if (inserted.length)
114
- await this.emit(run.id, "run", run);
115
- return inserted.length > 0;
105
+ return this.db.transaction(async (tx) => {
106
+ const { runs } = tables;
107
+ const inserted = await tx
108
+ .insert(runs)
109
+ .values({
110
+ scope: this.scope,
111
+ id: run.id,
112
+ taskKey: run.taskKey,
113
+ attempt: run.attempt,
114
+ record: redactValue(run, this.redact),
115
+ })
116
+ .onConflictDoNothing()
117
+ .returning({ id: runs.id });
118
+ if (inserted.length)
119
+ await tx.insert(tables.events).values(this.event(run.id, "run", run));
120
+ return inserted.length > 0;
121
+ });
116
122
  }
117
123
  async blockProject(id, reason) {
118
- const { projects } = tables;
119
- const updated = await this.db
120
- .update(projects)
121
- .set({ blocked: this.redact(reason) })
122
- .where(and(eq(projects.scope, this.scope), eq(projects.id, id), isNull(projects.blocked)))
123
- .returning({ id: projects.id });
124
- if (updated.length)
125
- await this.emit(null, "project", { id, blocked: reason });
124
+ await this.db.transaction(async (tx) => {
125
+ const { projects } = tables;
126
+ const updated = await tx
127
+ .update(projects)
128
+ .set({ blocked: this.redact(reason) })
129
+ .where(and(eq(projects.scope, this.scope), eq(projects.id, id), isNull(projects.blocked)))
130
+ .returning({ id: projects.id });
131
+ if (updated.length)
132
+ await tx
133
+ .insert(tables.events)
134
+ .values(this.event(null, "project", { id, blocked: reason }));
135
+ });
126
136
  }
127
137
  /**
128
138
  * Admit a retry and its events atomically. Safety checks run under the project
@@ -177,7 +187,7 @@ export class Store {
177
187
  taskKey: previous.taskKey,
178
188
  attempt,
179
189
  retryOf: previous.id,
180
- issue: previous.issue,
190
+ issue: target.issue ?? previous.issue,
181
191
  now,
182
192
  branchTemplate: target.branchTemplate,
183
193
  });
@@ -329,7 +339,7 @@ export class Store {
329
339
  const { runs } = tables;
330
340
  const change = { ...patch, updatedAt: new Date().toISOString() };
331
341
  const predicate = and(eq(runs.scope, this.scope), eq(runs.id, id));
332
- const record = await this.db.transaction(async (tx) => {
342
+ return this.db.transaction(async (tx) => {
333
343
  // Serialize read/merge/write so concurrent patches cannot lose fields.
334
344
  const [row] = await tx
335
345
  .select({ record: runs.record })
@@ -354,27 +364,30 @@ export class Store {
354
364
  execution.finishedAt = change.updatedAt;
355
365
  }
356
366
  await tx.update(runs).set({ record: merged }).where(predicate);
367
+ await tx.insert(tables.events).values(this.event(id, "run", change));
357
368
  return merged;
358
369
  });
359
- await this.emit(id, "run", change);
360
- return record;
361
370
  }
362
371
  async saveInvocation(record) {
363
- const { invocations } = tables;
364
- const persisted = redactValue(record, this.redact);
365
- await this.db
366
- .insert(invocations)
367
- .values({
368
- scope: this.scope,
369
- id: record.id,
370
- runId: record.runId,
371
- record: persisted,
372
- })
373
- .onConflictDoUpdate({
374
- target: invocations.id,
375
- set: { record: persisted },
372
+ await this.db.transaction(async (tx) => {
373
+ const { invocations } = tables;
374
+ const persisted = redactValue(record, this.redact);
375
+ await tx
376
+ .insert(invocations)
377
+ .values({
378
+ scope: this.scope,
379
+ id: record.id,
380
+ runId: record.runId,
381
+ record: persisted,
382
+ })
383
+ .onConflictDoUpdate({
384
+ target: invocations.id,
385
+ set: { record: persisted },
386
+ });
387
+ await tx
388
+ .insert(tables.events)
389
+ .values(this.event(record.runId, "invocation", record));
376
390
  });
377
- await this.emit(record.runId, "invocation", record);
378
391
  }
379
392
  async invocations(runId) {
380
393
  const { invocations } = tables;
@@ -387,7 +400,12 @@ export class Store {
387
400
  .sort((a, b) => a.startedAt.localeCompare(b.startedAt));
388
401
  }
389
402
  async emit(runId, kind, payload) {
390
- await this.db.insert(tables.events).values({
403
+ await this.db
404
+ .insert(tables.events)
405
+ .values(this.event(runId, kind, payload));
406
+ }
407
+ event(runId, kind, payload) {
408
+ return {
391
409
  scope: this.scope,
392
410
  runId,
393
411
  kind,
@@ -395,7 +413,7 @@ export class Store {
395
413
  payload: new SQL([
396
414
  new Param(JSON.stringify(redactValue(payload, this.redact))),
397
415
  ]),
398
- });
416
+ };
399
417
  }
400
418
  async events(after = 0, runId) {
401
419
  const { events } = tables;
@@ -9,7 +9,7 @@ export interface MonitorSource {
9
9
  request: Store["request"];
10
10
  commands: Store["commands"];
11
11
  }
12
- export type MonitorAction = "pause" | "resume" | "stop" | "retry" | "recover";
12
+ export type MonitorAction = "pause" | "resume" | "stop" | "retry" | "retry-refresh" | "recover";
13
13
  export declare function useMonitorData(source: MonitorSource, selection: {
14
14
  projectId?: string;
15
15
  runId?: string;
@@ -32,6 +32,7 @@ const helpPages = [
32
32
  ["p", "Pause or resume project intake"],
33
33
  ["s", "Stop selected run"],
34
34
  ["r", "Retry as a new run"],
35
+ ["R", "Retry with the current hosted issue"],
35
36
  ["c", "Recover failed publication"],
36
37
  ["[ / ]", "Inspect previous or next step event"],
37
38
  ["End", "Follow latest step event"],