@checkstack/automation-backend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +453 -0
- package/drizzle/0000_acoustic_diamondback.sql +80 -0
- package/drizzle/0001_mute_vindicator.sql +12 -0
- package/drizzle/0002_silky_omega_red.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +688 -0
- package/drizzle/meta/0001_snapshot.json +785 -0
- package/drizzle/meta/0002_snapshot.json +861 -0
- package/drizzle/meta/_journal.json +27 -0
- package/drizzle.config.ts +12 -0
- package/package.json +41 -0
- package/src/action-registry.ts +83 -0
- package/src/action-types.ts +324 -0
- package/src/artifact-store.ts +140 -0
- package/src/artifact-type-registry.ts +64 -0
- package/src/automation-store.ts +227 -0
- package/src/builtin-actions.test.ts +185 -0
- package/src/builtin-actions.ts +132 -0
- package/src/builtin-triggers.test.ts +264 -0
- package/src/builtin-triggers.ts +365 -0
- package/src/dispatch/action-kind.ts +44 -0
- package/src/dispatch/condition.ts +61 -0
- package/src/dispatch/delay-queue.ts +91 -0
- package/src/dispatch/engine.test.ts +1198 -0
- package/src/dispatch/engine.ts +1672 -0
- package/src/dispatch/path-nav.ts +65 -0
- package/src/dispatch/render.test.ts +75 -0
- package/src/dispatch/render.ts +136 -0
- package/src/dispatch/run-state-store.ts +143 -0
- package/src/dispatch/run-state.ts +298 -0
- package/src/dispatch/scope.test.ts +40 -0
- package/src/dispatch/scope.ts +125 -0
- package/src/dispatch/stalled-sweeper.ts +164 -0
- package/src/dispatch/test-fixtures.ts +558 -0
- package/src/dispatch/trigger-subscriber.ts +397 -0
- package/src/dispatch/types.ts +259 -0
- package/src/extension-points.ts +88 -0
- package/src/index.ts +379 -0
- package/src/migration/from-webhook-subscriptions.test.ts +237 -0
- package/src/migration/from-webhook-subscriptions.ts +398 -0
- package/src/registries.test.ts +357 -0
- package/src/router.test.ts +724 -0
- package/src/router.ts +556 -0
- package/src/schema.ts +310 -0
- package/src/trigger-registry.ts +99 -0
- package/src/validate-definition.test.ts +306 -0
- package/src/validate-definition.ts +304 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path navigation utilities for the action tree.
|
|
3
|
+
*
|
|
4
|
+
* Action paths are serialized as strings like
|
|
5
|
+
* `actions[1].choose[0].sequence[2]` and parsed back into structured
|
|
6
|
+
* `ActionPath` arrays.
|
|
7
|
+
*/
|
|
8
|
+
import type { ActionPath } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a path string emitted by `formatActionPath`.
|
|
12
|
+
*
|
|
13
|
+
* "actions[1].choose[0].sequence[2]"
|
|
14
|
+
* → ["actions", 1, "choose", 0, "sequence", 2]
|
|
15
|
+
*/
|
|
16
|
+
export function parseActionPath(serialized: string): ActionPath {
|
|
17
|
+
const segments: Array<string | number> = [];
|
|
18
|
+
// Either a `word` (key segment) or `[42]` (index segment); the literal
|
|
19
|
+
// `.` separators between them are intentionally ignored.
|
|
20
|
+
const pattern = /([a-z_]+)|\[(\d+)\]/gi;
|
|
21
|
+
let match: RegExpExecArray | null;
|
|
22
|
+
while ((match = pattern.exec(serialized)) !== null) {
|
|
23
|
+
if (match[1] !== undefined) {
|
|
24
|
+
segments.push(match[1]);
|
|
25
|
+
} else if (match[2] !== undefined) {
|
|
26
|
+
segments.push(Number(match[2]));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detect whether a serialized path contains any container segment that
|
|
34
|
+
* is forbidden for `wait_for_trigger` / `delay` in v1.
|
|
35
|
+
*
|
|
36
|
+
* Waits and delays are supported in the top-level `actions:` list and
|
|
37
|
+
* inside any depth of nested `choose` branches. They are forbidden
|
|
38
|
+
* inside `parallel` and `repeat` because suspension semantics for those
|
|
39
|
+
* containers require branch-level coordination state which is deferred
|
|
40
|
+
* to a follow-up. Validation throws at edit time and the runtime
|
|
41
|
+
* rejects at execution time as a defence in depth.
|
|
42
|
+
*/
|
|
43
|
+
export function isSuspensionAllowedAtPath(path: ActionPath): boolean {
|
|
44
|
+
for (const segment of path) {
|
|
45
|
+
if (segment === "parallel" || segment === "repeat") return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pop one container worth of segments off the end of the path.
|
|
52
|
+
*
|
|
53
|
+
* Used to walk back out after finishing a nested sequence: the dispatch
|
|
54
|
+
* engine knows the inner sequence is done, and asks the path util to
|
|
55
|
+
* give it the container's own path so it can find a sibling at that
|
|
56
|
+
* level.
|
|
57
|
+
*
|
|
58
|
+
* ["actions", 1, "choose", 0, "sequence", 2] → ["actions", 1, "choose", 0]
|
|
59
|
+
* ["actions", 1, "choose", 0] → ["actions", 1]
|
|
60
|
+
*/
|
|
61
|
+
export function popContainer(path: ActionPath): ActionPath {
|
|
62
|
+
// Container segments come in pairs (key, index). We pop two at a time.
|
|
63
|
+
if (path.length < 2) return path;
|
|
64
|
+
return path.slice(0, -2);
|
|
65
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createDefaultFilterRegistry,
|
|
4
|
+
type TemplateContext,
|
|
5
|
+
} from "@checkstack/template-engine";
|
|
6
|
+
import { renderConfig } from "./render";
|
|
7
|
+
|
|
8
|
+
const filters = createDefaultFilterRegistry();
|
|
9
|
+
const context: TemplateContext = {
|
|
10
|
+
trigger: { payload: { title: "Outage" } },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("renderConfig", () => {
|
|
14
|
+
it("renders {{ }} in ordinary fields", () => {
|
|
15
|
+
const out = renderConfig({
|
|
16
|
+
config: { message: "Incident {{ trigger.payload.title }} fired" },
|
|
17
|
+
jsonSchema: { properties: { message: { type: "string" } } },
|
|
18
|
+
context,
|
|
19
|
+
filters,
|
|
20
|
+
});
|
|
21
|
+
expect(out).toEqual({ message: "Incident Outage fired" });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("leaves native-code fields (shell/typescript/javascript) un-rendered", () => {
|
|
25
|
+
const out = renderConfig({
|
|
26
|
+
config: {
|
|
27
|
+
script: 'echo "{{ trigger.payload.title }}"',
|
|
28
|
+
message: "hi {{ trigger.payload.title }}",
|
|
29
|
+
},
|
|
30
|
+
jsonSchema: {
|
|
31
|
+
properties: {
|
|
32
|
+
script: { "x-editor-types": ["shell"] },
|
|
33
|
+
message: { type: "string" },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
context,
|
|
37
|
+
filters,
|
|
38
|
+
});
|
|
39
|
+
// The code field's template markers stay literal; the plain field renders.
|
|
40
|
+
expect(out).toEqual({
|
|
41
|
+
script: 'echo "{{ trigger.payload.title }}"',
|
|
42
|
+
message: "hi Outage",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("treats typescript and javascript editor types as code too", () => {
|
|
47
|
+
const out = renderConfig({
|
|
48
|
+
config: { ts: "const x = {{ trigger.payload.title }};" },
|
|
49
|
+
jsonSchema: { properties: { ts: { "x-editor-types": ["typescript"] } } },
|
|
50
|
+
context,
|
|
51
|
+
filters,
|
|
52
|
+
});
|
|
53
|
+
expect(out).toEqual({ ts: "const x = {{ trigger.payload.title }};" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("falls back to plain rendering when no schema is supplied", () => {
|
|
57
|
+
const out = renderConfig({
|
|
58
|
+
config: { a: "{{ trigger.payload.title }}" },
|
|
59
|
+
jsonSchema: undefined,
|
|
60
|
+
context,
|
|
61
|
+
filters,
|
|
62
|
+
});
|
|
63
|
+
expect(out).toEqual({ a: "Outage" });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("passes non-object config straight through the renderer", () => {
|
|
67
|
+
const out = renderConfig({
|
|
68
|
+
config: "{{ trigger.payload.title }}",
|
|
69
|
+
jsonSchema: undefined,
|
|
70
|
+
context,
|
|
71
|
+
filters,
|
|
72
|
+
});
|
|
73
|
+
expect(out).toBe("Outage");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive template rendering for action `config` objects.
|
|
3
|
+
*
|
|
4
|
+
* Action configs are arbitrary JSON: strings, numbers, booleans, arrays,
|
|
5
|
+
* objects. Strings may carry `{{ }}` interpolation that should be
|
|
6
|
+
* rendered against the current scope. This helper walks the object tree
|
|
7
|
+
* and renders every string value in place, leaving non-strings alone.
|
|
8
|
+
*
|
|
9
|
+
* Errors from individual templates surface up — the dispatch engine
|
|
10
|
+
* catches and converts them into a failed step.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
evaluate as evaluateExpression,
|
|
14
|
+
parseCondition,
|
|
15
|
+
parseTemplate,
|
|
16
|
+
render as renderTemplate,
|
|
17
|
+
type FilterRegistry,
|
|
18
|
+
type TemplateContext,
|
|
19
|
+
} from "@checkstack/template-engine";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Editor-type tags that denote a native-code field. Such fields are
|
|
23
|
+
* passed through verbatim by {@link renderConfig} — their `{{ }}`, if
|
|
24
|
+
* any, is NOT expanded, because script actions read run data from the
|
|
25
|
+
* typed `context` (TS) or injected env vars (shell), not from templates.
|
|
26
|
+
*/
|
|
27
|
+
const CODE_EDITOR_TYPES = new Set(["shell", "typescript", "javascript"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Narrow an unknown JSON value to a string-keyed record after a runtime
|
|
31
|
+
* type check. The single cast is the canonical safe-narrowing pattern —
|
|
32
|
+
* JSON schemas arrive as `Record<string, unknown>`, so reading nested
|
|
33
|
+
* shape requires asserting the post-check type.
|
|
34
|
+
*/
|
|
35
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
36
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
37
|
+
? (value as Record<string, unknown>)
|
|
38
|
+
: {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasCodeEditorType(propSchema: unknown): boolean {
|
|
42
|
+
const types = asRecord(propSchema)["x-editor-types"];
|
|
43
|
+
return (
|
|
44
|
+
Array.isArray(types) &&
|
|
45
|
+
types.some((t) => typeof t === "string" && CODE_EDITOR_TYPES.has(t))
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render an action's top-level `config`, skipping native-code fields.
|
|
51
|
+
*
|
|
52
|
+
* Identical to {@link renderValue} for every field EXCEPT those whose
|
|
53
|
+
* JSON schema declares an `x-editor-types` of `shell` / `typescript` /
|
|
54
|
+
* `javascript`: those are returned verbatim so any `{{ }}` stays literal.
|
|
55
|
+
* This removes the deprecated "templates inside a code field" path so it
|
|
56
|
+
* can't be used by accident — code fields get run data via `context` /
|
|
57
|
+
* env vars instead. Falls back to {@link renderValue} when the config
|
|
58
|
+
* isn't a plain object or no schema is available.
|
|
59
|
+
*/
|
|
60
|
+
export function renderConfig(args: {
|
|
61
|
+
config: unknown;
|
|
62
|
+
jsonSchema: Record<string, unknown> | undefined;
|
|
63
|
+
context: TemplateContext;
|
|
64
|
+
filters: FilterRegistry;
|
|
65
|
+
}): unknown {
|
|
66
|
+
const { config, jsonSchema, context, filters } = args;
|
|
67
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
68
|
+
return renderValue(config, context, filters);
|
|
69
|
+
}
|
|
70
|
+
const properties = asRecord(jsonSchema?.["properties"]);
|
|
71
|
+
const out: Record<string, unknown> = {};
|
|
72
|
+
for (const [key, value] of Object.entries(asRecord(config))) {
|
|
73
|
+
out[key] = hasCodeEditorType(properties[key])
|
|
74
|
+
? value
|
|
75
|
+
: renderValue(value, context, filters);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render any value, recursing into objects and arrays. Strings are
|
|
82
|
+
* passed through the template engine; everything else is returned as-is.
|
|
83
|
+
*/
|
|
84
|
+
export function renderValue(
|
|
85
|
+
value: unknown,
|
|
86
|
+
context: TemplateContext,
|
|
87
|
+
filters: FilterRegistry,
|
|
88
|
+
): unknown {
|
|
89
|
+
if (typeof value === "string") {
|
|
90
|
+
return renderString(value, context, filters);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
return value.map((v) => renderValue(v, context, filters));
|
|
94
|
+
}
|
|
95
|
+
if (value && typeof value === "object") {
|
|
96
|
+
const out: Record<string, unknown> = {};
|
|
97
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
98
|
+
out[k] = renderValue(v, context, filters);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Render a single string template. If the string contains no `{{`
|
|
107
|
+
* markers, this is effectively a passthrough (the template engine
|
|
108
|
+
* tokenises trivial cases very cheaply).
|
|
109
|
+
*
|
|
110
|
+
* For pure expressions like `"{{ count + 1 }}"` the renderer returns a
|
|
111
|
+
* stringified value — callers that need the typed primitive should use
|
|
112
|
+
* `renderExpression` instead.
|
|
113
|
+
*/
|
|
114
|
+
export function renderString(
|
|
115
|
+
template: string,
|
|
116
|
+
context: TemplateContext,
|
|
117
|
+
filters: FilterRegistry,
|
|
118
|
+
): string {
|
|
119
|
+
if (!template.includes("{{")) return template;
|
|
120
|
+
return renderTemplate(parseTemplate(template), context, { filters });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Evaluate an expression and return the raw (un-stringified) value.
|
|
125
|
+
*
|
|
126
|
+
* Use this when the dispatched action's schema expects a typed primitive
|
|
127
|
+
* (e.g. `delay: { seconds: "{{ trigger.payload.retryAfter }}" }` — the
|
|
128
|
+
* action expects a number, not a string).
|
|
129
|
+
*/
|
|
130
|
+
export function renderExpression(
|
|
131
|
+
source: string,
|
|
132
|
+
context: TemplateContext,
|
|
133
|
+
filters: FilterRegistry,
|
|
134
|
+
): unknown {
|
|
135
|
+
return evaluateExpression(parseCondition(source), context, { filters });
|
|
136
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle-backed durable scope + heartbeat persistence and Postgres
|
|
3
|
+
* advisory-lock helpers for the dispatch engine.
|
|
4
|
+
*
|
|
5
|
+
* Scope snapshots are written after every successful step so a future
|
|
6
|
+
* process can resume the run exactly where the prior process left off.
|
|
7
|
+
* Heartbeats let the stalled-run sweeper distinguish healthy in-flight
|
|
8
|
+
* runs from runs whose host crashed mid-execution.
|
|
9
|
+
*
|
|
10
|
+
* Advisory locks ensure at most one instance is executing a given run
|
|
11
|
+
* at a time. The lock auto-releases when the holding connection dies —
|
|
12
|
+
* exactly what we want during crash recovery.
|
|
13
|
+
*/
|
|
14
|
+
import { lt, eq, sql } from "drizzle-orm";
|
|
15
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
16
|
+
|
|
17
|
+
import { automationRunState } from "../schema";
|
|
18
|
+
|
|
19
|
+
export interface RunStateSnapshot {
|
|
20
|
+
scopeSnapshot: Record<string, unknown>;
|
|
21
|
+
lastActionPath: string | null;
|
|
22
|
+
lastHeartbeatAt: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunStateStore {
|
|
26
|
+
/**
|
|
27
|
+
* Write or update the per-run durable state. `lastActionPath` is the
|
|
28
|
+
* path of the most recently completed action — resume walks the tree
|
|
29
|
+
* looking for this path and treats the action at it as already done.
|
|
30
|
+
*/
|
|
31
|
+
upsert(input: {
|
|
32
|
+
runId: string;
|
|
33
|
+
scopeSnapshot: Record<string, unknown>;
|
|
34
|
+
lastActionPath: string | null;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
|
|
37
|
+
load(runId: string): Promise<RunStateSnapshot | undefined>;
|
|
38
|
+
|
|
39
|
+
/** Drop the state row — done at terminal run status. */
|
|
40
|
+
clear(runId: string): Promise<void>;
|
|
41
|
+
|
|
42
|
+
/** Bump only the heartbeat. Used by long-running container handlers. */
|
|
43
|
+
heartbeat(runId: string): Promise<void>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run ids whose heartbeat is older than `threshold`. Returned in
|
|
47
|
+
* heartbeat-ascending order so the sweeper processes the most
|
|
48
|
+
* stale first.
|
|
49
|
+
*/
|
|
50
|
+
findStalledRunIds(threshold: Date): Promise<string[]>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Try to acquire a Postgres session-level advisory lock for the run.
|
|
54
|
+
* Returns true on acquisition. The lock auto-releases when the holding
|
|
55
|
+
* DB session closes (e.g. on process crash), so dead instances don't
|
|
56
|
+
* leak locks.
|
|
57
|
+
*/
|
|
58
|
+
tryAdvisoryLock(runId: string): Promise<boolean>;
|
|
59
|
+
|
|
60
|
+
/** Release a previously-acquired advisory lock. */
|
|
61
|
+
releaseAdvisoryLock(runId: string): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Schema = { automationRunState: typeof automationRunState };
|
|
65
|
+
|
|
66
|
+
export function createRunStateStore(
|
|
67
|
+
db: SafeDatabase<Schema>,
|
|
68
|
+
): RunStateStore {
|
|
69
|
+
return {
|
|
70
|
+
async upsert(input) {
|
|
71
|
+
await db
|
|
72
|
+
.insert(automationRunState)
|
|
73
|
+
.values({
|
|
74
|
+
runId: input.runId,
|
|
75
|
+
scopeSnapshot: input.scopeSnapshot,
|
|
76
|
+
lastActionPath: input.lastActionPath,
|
|
77
|
+
})
|
|
78
|
+
.onConflictDoUpdate({
|
|
79
|
+
target: automationRunState.runId,
|
|
80
|
+
set: {
|
|
81
|
+
scopeSnapshot: input.scopeSnapshot,
|
|
82
|
+
lastActionPath: input.lastActionPath,
|
|
83
|
+
lastHeartbeatAt: new Date(),
|
|
84
|
+
updatedAt: new Date(),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async load(runId) {
|
|
90
|
+
const rows = await db
|
|
91
|
+
.select()
|
|
92
|
+
.from(automationRunState)
|
|
93
|
+
.where(eq(automationRunState.runId, runId))
|
|
94
|
+
.limit(1);
|
|
95
|
+
const row = rows[0];
|
|
96
|
+
if (!row) return;
|
|
97
|
+
return {
|
|
98
|
+
scopeSnapshot: row.scopeSnapshot,
|
|
99
|
+
lastActionPath: row.lastActionPath,
|
|
100
|
+
lastHeartbeatAt: row.lastHeartbeatAt,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async clear(runId) {
|
|
105
|
+
await db
|
|
106
|
+
.delete(automationRunState)
|
|
107
|
+
.where(eq(automationRunState.runId, runId));
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async heartbeat(runId) {
|
|
111
|
+
await db
|
|
112
|
+
.update(automationRunState)
|
|
113
|
+
.set({ lastHeartbeatAt: new Date() })
|
|
114
|
+
.where(eq(automationRunState.runId, runId));
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async findStalledRunIds(threshold) {
|
|
118
|
+
const rows = await db
|
|
119
|
+
.select({ runId: automationRunState.runId })
|
|
120
|
+
.from(automationRunState)
|
|
121
|
+
.where(lt(automationRunState.lastHeartbeatAt, threshold))
|
|
122
|
+
.orderBy(automationRunState.lastHeartbeatAt);
|
|
123
|
+
return rows.map((r) => r.runId);
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async tryAdvisoryLock(runId) {
|
|
127
|
+
// hashtextextended returns int8 in Postgres, which pg_try_advisory_lock
|
|
128
|
+
// accepts directly. Using a deterministic hash means the same runId
|
|
129
|
+
// always maps to the same lock key across processes.
|
|
130
|
+
const result = await db.execute<{ ok: boolean }>(sql`
|
|
131
|
+
SELECT pg_try_advisory_lock(hashtextextended(${runId}, 0)) AS ok
|
|
132
|
+
`);
|
|
133
|
+
const rows = result as unknown as { rows: Array<{ ok: boolean }> };
|
|
134
|
+
return Boolean(rows.rows?.[0]?.ok);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async releaseAdvisoryLock(runId) {
|
|
138
|
+
await db.execute(sql`
|
|
139
|
+
SELECT pg_advisory_unlock(hashtextextended(${runId}, 0))
|
|
140
|
+
`);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|