@desplega.ai/agent-swarm 1.74.1 → 1.74.3
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/openapi.json +199 -1
- package/package.json +1 -1
- package/src/be/db.ts +278 -0
- package/src/be/migrations/049_wait_states.sql +30 -0
- package/src/be/migrations/050_wait_states_scope.sql +19 -0
- package/src/http/index.ts +2 -0
- package/src/http/trackers/jira.ts +84 -27
- package/src/http/trackers/linear.ts +67 -11
- package/src/http/utils.ts +15 -0
- package/src/http/workflow-events.ts +107 -0
- package/src/http/workflows.ts +55 -6
- package/src/jira/sync.ts +20 -7
- package/src/linear/gate.ts +122 -0
- package/src/linear/sync.ts +128 -0
- package/src/oauth/keepalive.ts +34 -13
- package/src/providers/claude-adapter.ts +27 -23
- package/src/tests/claude-adapter.test.ts +161 -2
- package/src/tests/ensure-token.test.ts +33 -0
- package/src/tests/linear-webhook.test.ts +383 -0
- package/src/tests/workflow-executors.test.ts +4 -2
- package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
- package/src/tests/workflow-patch.test.ts +14 -14
- package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
- package/src/tests/workflow-wait-event.test.ts +384 -0
- package/src/tests/workflow-wait-filter.test.ts +200 -0
- package/src/tests/workflow-wait-http.test.ts +177 -0
- package/src/tests/workflow-wait-recovery.test.ts +178 -0
- package/src/tests/workflow-wait-state-queries.test.ts +419 -0
- package/src/tests/workflow-wait-time.test.ts +255 -0
- package/src/tools/tracker/tracker-status.ts +7 -1
- package/src/tools/workflows/create-workflow.ts +16 -2
- package/src/tools/workflows/patch-workflow.ts +26 -6
- package/src/tools/workflows/trigger-workflow.ts +26 -1
- package/src/tools/workflows/update-workflow.ts +28 -2
- package/src/types.ts +48 -3
- package/src/workflows/definition.ts +2 -5
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/wait.ts +170 -0
- package/src/workflows/index.ts +18 -2
- package/src/workflows/json-schema-validator.ts +8 -1
- package/src/workflows/recovery.ts +55 -1
- package/src/workflows/resume.ts +272 -0
- package/src/workflows/wait-filter.ts +311 -0
- package/src/workflows/wait-poller.ts +63 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.74.
|
|
5
|
+
"version": "1.74.3",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -7343,6 +7343,33 @@
|
|
|
7343
7343
|
}
|
|
7344
7344
|
}
|
|
7345
7345
|
},
|
|
7346
|
+
"/api/trackers/jira/refresh": {
|
|
7347
|
+
"post": {
|
|
7348
|
+
"summary": "Force a Jira OAuth token refresh and return the updated status payload. Useful when an agent observes an expired token via tracker-status / db-query and wants to recover without restarting the server or re-running 3LO.",
|
|
7349
|
+
"tags": [
|
|
7350
|
+
"Trackers"
|
|
7351
|
+
],
|
|
7352
|
+
"security": [
|
|
7353
|
+
{
|
|
7354
|
+
"bearerAuth": []
|
|
7355
|
+
}
|
|
7356
|
+
],
|
|
7357
|
+
"responses": {
|
|
7358
|
+
"200": {
|
|
7359
|
+
"description": "Token refreshed; returns same shape as /status"
|
|
7360
|
+
},
|
|
7361
|
+
"409": {
|
|
7362
|
+
"description": "Jira not connected (no refresh token stored)"
|
|
7363
|
+
},
|
|
7364
|
+
"500": {
|
|
7365
|
+
"description": "Refresh failed (e.g. revoked grant, network error)"
|
|
7366
|
+
},
|
|
7367
|
+
"503": {
|
|
7368
|
+
"description": "Jira integration not configured"
|
|
7369
|
+
}
|
|
7370
|
+
}
|
|
7371
|
+
}
|
|
7372
|
+
},
|
|
7346
7373
|
"/api/trackers/jira/webhook/{token}": {
|
|
7347
7374
|
"post": {
|
|
7348
7375
|
"summary": "Receive Jira webhook events (URL-token authenticated). Phase 2 stub — Phase 3 fills in dispatch.",
|
|
@@ -7547,6 +7574,33 @@
|
|
|
7547
7574
|
}
|
|
7548
7575
|
}
|
|
7549
7576
|
},
|
|
7577
|
+
"/api/trackers/linear/refresh": {
|
|
7578
|
+
"post": {
|
|
7579
|
+
"summary": "Force a Linear OAuth token refresh and return the updated status payload. Useful when an agent observes an expired token and wants to recover without restarting the server or re-running OAuth.",
|
|
7580
|
+
"tags": [
|
|
7581
|
+
"Trackers"
|
|
7582
|
+
],
|
|
7583
|
+
"security": [
|
|
7584
|
+
{
|
|
7585
|
+
"bearerAuth": []
|
|
7586
|
+
}
|
|
7587
|
+
],
|
|
7588
|
+
"responses": {
|
|
7589
|
+
"200": {
|
|
7590
|
+
"description": "Token refreshed; returns same shape as /status"
|
|
7591
|
+
},
|
|
7592
|
+
"409": {
|
|
7593
|
+
"description": "Linear not connected (no refresh token stored)"
|
|
7594
|
+
},
|
|
7595
|
+
"500": {
|
|
7596
|
+
"description": "Refresh failed"
|
|
7597
|
+
},
|
|
7598
|
+
"503": {
|
|
7599
|
+
"description": "Linear integration not configured"
|
|
7600
|
+
}
|
|
7601
|
+
}
|
|
7602
|
+
}
|
|
7603
|
+
},
|
|
7550
7604
|
"/api/trackers/linear/webhook": {
|
|
7551
7605
|
"post": {
|
|
7552
7606
|
"summary": "Handle Linear webhook events (signature-verified)",
|
|
@@ -7644,6 +7698,108 @@
|
|
|
7644
7698
|
}
|
|
7645
7699
|
}
|
|
7646
7700
|
},
|
|
7701
|
+
"/api/workflow-runs/{runId}/events": {
|
|
7702
|
+
"post": {
|
|
7703
|
+
"summary": "Fire a run-scoped event signal",
|
|
7704
|
+
"description": "Emits an event onto the workflow event bus with `_runId` injected. Used by wait nodes in `event` mode with `scope: 'run'`. The body's `name` is the bus event name; `payload` is forwarded as-is plus `_runId`.",
|
|
7705
|
+
"tags": [
|
|
7706
|
+
"WorkflowEvents"
|
|
7707
|
+
],
|
|
7708
|
+
"security": [
|
|
7709
|
+
{
|
|
7710
|
+
"bearerAuth": []
|
|
7711
|
+
}
|
|
7712
|
+
],
|
|
7713
|
+
"parameters": [
|
|
7714
|
+
{
|
|
7715
|
+
"schema": {
|
|
7716
|
+
"type": "string",
|
|
7717
|
+
"format": "uuid"
|
|
7718
|
+
},
|
|
7719
|
+
"required": true,
|
|
7720
|
+
"name": "runId",
|
|
7721
|
+
"in": "path"
|
|
7722
|
+
}
|
|
7723
|
+
],
|
|
7724
|
+
"requestBody": {
|
|
7725
|
+
"content": {
|
|
7726
|
+
"application/json": {
|
|
7727
|
+
"schema": {
|
|
7728
|
+
"type": "object",
|
|
7729
|
+
"properties": {
|
|
7730
|
+
"name": {
|
|
7731
|
+
"type": "string",
|
|
7732
|
+
"minLength": 1
|
|
7733
|
+
},
|
|
7734
|
+
"payload": {
|
|
7735
|
+
"type": "object",
|
|
7736
|
+
"additionalProperties": {}
|
|
7737
|
+
}
|
|
7738
|
+
},
|
|
7739
|
+
"required": [
|
|
7740
|
+
"name"
|
|
7741
|
+
]
|
|
7742
|
+
}
|
|
7743
|
+
}
|
|
7744
|
+
}
|
|
7745
|
+
},
|
|
7746
|
+
"responses": {
|
|
7747
|
+
"200": {
|
|
7748
|
+
"description": "Event emitted"
|
|
7749
|
+
},
|
|
7750
|
+
"400": {
|
|
7751
|
+
"description": "Validation error"
|
|
7752
|
+
},
|
|
7753
|
+
"404": {
|
|
7754
|
+
"description": "Workflow run not found"
|
|
7755
|
+
}
|
|
7756
|
+
}
|
|
7757
|
+
}
|
|
7758
|
+
},
|
|
7759
|
+
"/api/workflow-events": {
|
|
7760
|
+
"post": {
|
|
7761
|
+
"summary": "Fire a global workflow event signal",
|
|
7762
|
+
"description": "Emits an event onto the workflow event bus. Wait-states with `scope: 'global'` may match. Run-scoped waits will NOT match this broadcast unless the payload carries a matching `workflowRunId`.",
|
|
7763
|
+
"tags": [
|
|
7764
|
+
"WorkflowEvents"
|
|
7765
|
+
],
|
|
7766
|
+
"security": [
|
|
7767
|
+
{
|
|
7768
|
+
"bearerAuth": []
|
|
7769
|
+
}
|
|
7770
|
+
],
|
|
7771
|
+
"requestBody": {
|
|
7772
|
+
"content": {
|
|
7773
|
+
"application/json": {
|
|
7774
|
+
"schema": {
|
|
7775
|
+
"type": "object",
|
|
7776
|
+
"properties": {
|
|
7777
|
+
"name": {
|
|
7778
|
+
"type": "string",
|
|
7779
|
+
"minLength": 1
|
|
7780
|
+
},
|
|
7781
|
+
"payload": {
|
|
7782
|
+
"type": "object",
|
|
7783
|
+
"additionalProperties": {}
|
|
7784
|
+
}
|
|
7785
|
+
},
|
|
7786
|
+
"required": [
|
|
7787
|
+
"name"
|
|
7788
|
+
]
|
|
7789
|
+
}
|
|
7790
|
+
}
|
|
7791
|
+
}
|
|
7792
|
+
},
|
|
7793
|
+
"responses": {
|
|
7794
|
+
"200": {
|
|
7795
|
+
"description": "Event emitted"
|
|
7796
|
+
},
|
|
7797
|
+
"400": {
|
|
7798
|
+
"description": "Validation error"
|
|
7799
|
+
}
|
|
7800
|
+
}
|
|
7801
|
+
}
|
|
7802
|
+
},
|
|
7647
7803
|
"/api/workflows": {
|
|
7648
7804
|
"get": {
|
|
7649
7805
|
"summary": "List all workflows",
|
|
@@ -8651,6 +8807,14 @@
|
|
|
8651
8807
|
"continue"
|
|
8652
8808
|
],
|
|
8653
8809
|
"description": "Update the definition-level onNodeFailure behavior"
|
|
8810
|
+
},
|
|
8811
|
+
"triggerSchema": {
|
|
8812
|
+
"type": [
|
|
8813
|
+
"object",
|
|
8814
|
+
"null"
|
|
8815
|
+
],
|
|
8816
|
+
"additionalProperties": {},
|
|
8817
|
+
"description": "Optional JSON-Schema describing the expected trigger payload shape. Pass an object to set/replace; pass null to clear; omit to leave unchanged. Validator subset: type, required, properties, enum, const, items. Other JSON-Schema keywords are silently ignored."
|
|
8654
8818
|
}
|
|
8655
8819
|
}
|
|
8656
8820
|
}
|
|
@@ -8917,6 +9081,40 @@
|
|
|
8917
9081
|
}
|
|
8918
9082
|
}
|
|
8919
9083
|
},
|
|
9084
|
+
"/api/workflows/{id}/trigger/validate": {
|
|
9085
|
+
"post": {
|
|
9086
|
+
"summary": "Validate a payload against the workflow's triggerSchema (no run)",
|
|
9087
|
+
"tags": [
|
|
9088
|
+
"Workflows"
|
|
9089
|
+
],
|
|
9090
|
+
"security": [
|
|
9091
|
+
{
|
|
9092
|
+
"bearerAuth": []
|
|
9093
|
+
}
|
|
9094
|
+
],
|
|
9095
|
+
"parameters": [
|
|
9096
|
+
{
|
|
9097
|
+
"schema": {
|
|
9098
|
+
"type": "string"
|
|
9099
|
+
},
|
|
9100
|
+
"required": true,
|
|
9101
|
+
"name": "id",
|
|
9102
|
+
"in": "path"
|
|
9103
|
+
}
|
|
9104
|
+
],
|
|
9105
|
+
"responses": {
|
|
9106
|
+
"200": {
|
|
9107
|
+
"description": "Payload matches the workflow's triggerSchema (or workflow has none)"
|
|
9108
|
+
},
|
|
9109
|
+
"400": {
|
|
9110
|
+
"description": "Payload failed validation; body matches the TriggerSchemaError contract"
|
|
9111
|
+
},
|
|
9112
|
+
"404": {
|
|
9113
|
+
"description": "Workflow not found"
|
|
9114
|
+
}
|
|
9115
|
+
}
|
|
9116
|
+
}
|
|
9117
|
+
},
|
|
8920
9118
|
"/api/workflows/{id}/runs": {
|
|
8921
9119
|
"get": {
|
|
8922
9120
|
"summary": "List runs for a workflow",
|
package/package.json
CHANGED
package/src/be/db.ts
CHANGED
|
@@ -58,6 +58,9 @@ import type {
|
|
|
58
58
|
User,
|
|
59
59
|
VersionableField,
|
|
60
60
|
VersionMeta,
|
|
61
|
+
WaitMode,
|
|
62
|
+
WaitStateRow,
|
|
63
|
+
WaitStateStatus,
|
|
61
64
|
Workflow,
|
|
62
65
|
WorkflowDefinition,
|
|
63
66
|
WorkflowRun,
|
|
@@ -6781,6 +6784,281 @@ export function getExpiredPendingApprovals(): ApprovalRequest[] {
|
|
|
6781
6784
|
return rows.map(rowToApprovalRequest);
|
|
6782
6785
|
}
|
|
6783
6786
|
|
|
6787
|
+
// ============================================================================
|
|
6788
|
+
// Wait States (workflow `wait` node side table)
|
|
6789
|
+
// ============================================================================
|
|
6790
|
+
//
|
|
6791
|
+
// Mirrors approval-request helpers above. Time-mode rows carry `wakeUpAt`;
|
|
6792
|
+
// event-mode rows carry `eventName` + optional `eventFilter` (object or
|
|
6793
|
+
// arrow-fn body string) and optional `expiresAt`. `resolveWaitState` is the
|
|
6794
|
+
// race-safe transition gate — concurrent callers (poller + bus listener)
|
|
6795
|
+
// rely on `WHERE status='pending'` so only the first one wins.
|
|
6796
|
+
|
|
6797
|
+
interface WaitStateRowDb {
|
|
6798
|
+
id: string;
|
|
6799
|
+
workflowRunId: string;
|
|
6800
|
+
workflowRunStepId: string;
|
|
6801
|
+
mode: string;
|
|
6802
|
+
wakeUpAt: string | null;
|
|
6803
|
+
eventName: string | null;
|
|
6804
|
+
eventFilter: string | null;
|
|
6805
|
+
expiresAt: string | null;
|
|
6806
|
+
status: string;
|
|
6807
|
+
firedPayload: string | null;
|
|
6808
|
+
resolvedAt: string | null;
|
|
6809
|
+
createdAt: string;
|
|
6810
|
+
updatedAt: string;
|
|
6811
|
+
eventScope: string;
|
|
6812
|
+
}
|
|
6813
|
+
|
|
6814
|
+
function rowToWaitState(row: WaitStateRowDb): WaitStateRow {
|
|
6815
|
+
let parsedFilter: WaitStateRow["eventFilter"] = null;
|
|
6816
|
+
if (row.eventFilter !== null) {
|
|
6817
|
+
// eventFilter is stored as JSON: either an object or a JSON-encoded string.
|
|
6818
|
+
try {
|
|
6819
|
+
const decoded = JSON.parse(row.eventFilter);
|
|
6820
|
+
// Accept both shapes — string filter (arrow-fn body) or object filter.
|
|
6821
|
+
if (typeof decoded === "string" || (typeof decoded === "object" && decoded !== null)) {
|
|
6822
|
+
parsedFilter = decoded as WaitStateRow["eventFilter"];
|
|
6823
|
+
}
|
|
6824
|
+
} catch {
|
|
6825
|
+
parsedFilter = null;
|
|
6826
|
+
}
|
|
6827
|
+
}
|
|
6828
|
+
return {
|
|
6829
|
+
id: row.id,
|
|
6830
|
+
workflowRunId: row.workflowRunId,
|
|
6831
|
+
workflowRunStepId: row.workflowRunStepId,
|
|
6832
|
+
mode: row.mode as WaitMode,
|
|
6833
|
+
wakeUpAt: normalizeDate(row.wakeUpAt),
|
|
6834
|
+
eventName: row.eventName,
|
|
6835
|
+
eventFilter: parsedFilter,
|
|
6836
|
+
expiresAt: normalizeDate(row.expiresAt),
|
|
6837
|
+
status: row.status as WaitStateStatus,
|
|
6838
|
+
firedPayload: row.firedPayload ? JSON.parse(row.firedPayload) : null,
|
|
6839
|
+
resolvedAt: normalizeDate(row.resolvedAt),
|
|
6840
|
+
createdAt: normalizeDateRequired(row.createdAt),
|
|
6841
|
+
updatedAt: normalizeDateRequired(row.updatedAt),
|
|
6842
|
+
eventScope: (row.eventScope as "run" | "global") ?? "run",
|
|
6843
|
+
};
|
|
6844
|
+
}
|
|
6845
|
+
|
|
6846
|
+
export interface CreateWaitStateInput {
|
|
6847
|
+
id: string;
|
|
6848
|
+
workflowRunId: string;
|
|
6849
|
+
workflowRunStepId: string;
|
|
6850
|
+
mode: WaitMode;
|
|
6851
|
+
wakeUpAt?: string | null;
|
|
6852
|
+
eventName?: string | null;
|
|
6853
|
+
eventFilter?: Record<string, unknown> | string | null;
|
|
6854
|
+
expiresAt?: string | null;
|
|
6855
|
+
scope?: "run" | "global";
|
|
6856
|
+
}
|
|
6857
|
+
|
|
6858
|
+
export function createWaitState(input: CreateWaitStateInput): WaitStateRow {
|
|
6859
|
+
const now = new Date().toISOString();
|
|
6860
|
+
const row = getDb()
|
|
6861
|
+
.prepare<
|
|
6862
|
+
WaitStateRowDb,
|
|
6863
|
+
[
|
|
6864
|
+
string,
|
|
6865
|
+
string,
|
|
6866
|
+
string,
|
|
6867
|
+
string,
|
|
6868
|
+
string | null,
|
|
6869
|
+
string | null,
|
|
6870
|
+
string | null,
|
|
6871
|
+
string | null,
|
|
6872
|
+
string,
|
|
6873
|
+
string,
|
|
6874
|
+
string,
|
|
6875
|
+
]
|
|
6876
|
+
>(
|
|
6877
|
+
`INSERT INTO wait_states
|
|
6878
|
+
(id, workflowRunId, workflowRunStepId, mode, wakeUpAt, eventName, eventFilter, expiresAt, eventScope, createdAt, updatedAt)
|
|
6879
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
6880
|
+
RETURNING *`,
|
|
6881
|
+
)
|
|
6882
|
+
.get(
|
|
6883
|
+
input.id,
|
|
6884
|
+
input.workflowRunId,
|
|
6885
|
+
input.workflowRunStepId,
|
|
6886
|
+
input.mode,
|
|
6887
|
+
input.wakeUpAt ?? null,
|
|
6888
|
+
input.eventName ?? null,
|
|
6889
|
+
input.eventFilter !== undefined && input.eventFilter !== null
|
|
6890
|
+
? JSON.stringify(input.eventFilter)
|
|
6891
|
+
: null,
|
|
6892
|
+
input.expiresAt ?? null,
|
|
6893
|
+
input.scope ?? "run",
|
|
6894
|
+
now,
|
|
6895
|
+
now,
|
|
6896
|
+
);
|
|
6897
|
+
return rowToWaitState(row!);
|
|
6898
|
+
}
|
|
6899
|
+
|
|
6900
|
+
export function getWaitStateById(id: string): WaitStateRow | null {
|
|
6901
|
+
const row = getDb()
|
|
6902
|
+
.prepare<WaitStateRowDb, [string]>("SELECT * FROM wait_states WHERE id = ?")
|
|
6903
|
+
.get(id);
|
|
6904
|
+
return row ? rowToWaitState(row) : null;
|
|
6905
|
+
}
|
|
6906
|
+
|
|
6907
|
+
/**
|
|
6908
|
+
* Idempotency lookup — mirrors `getApprovalRequestByStepId`. A re-execution of
|
|
6909
|
+
* the same wait node finds its existing row instead of inserting a duplicate.
|
|
6910
|
+
*/
|
|
6911
|
+
export function getWaitStateByStepId(stepId: string): WaitStateRow | null {
|
|
6912
|
+
const row = getDb()
|
|
6913
|
+
.prepare<WaitStateRowDb, [string]>("SELECT * FROM wait_states WHERE workflowRunStepId = ?")
|
|
6914
|
+
.get(stepId);
|
|
6915
|
+
return row ? rowToWaitState(row) : null;
|
|
6916
|
+
}
|
|
6917
|
+
|
|
6918
|
+
/**
|
|
6919
|
+
* Scan for waits the poller should resume now:
|
|
6920
|
+
* - mode='time' with `wakeUpAt <= now`, OR
|
|
6921
|
+
* - mode='event' with non-null `expiresAt <= now` (timeout branch).
|
|
6922
|
+
*/
|
|
6923
|
+
export function getDueWaitStates(): WaitStateRow[] {
|
|
6924
|
+
const rows = getDb()
|
|
6925
|
+
.prepare<WaitStateRowDb, []>(
|
|
6926
|
+
`SELECT * FROM wait_states
|
|
6927
|
+
WHERE status = 'pending'
|
|
6928
|
+
AND (
|
|
6929
|
+
(mode = 'time' AND wakeUpAt IS NOT NULL
|
|
6930
|
+
AND wakeUpAt <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
6931
|
+
OR
|
|
6932
|
+
(mode = 'event' AND expiresAt IS NOT NULL
|
|
6933
|
+
AND expiresAt <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
6934
|
+
)`,
|
|
6935
|
+
)
|
|
6936
|
+
.all();
|
|
6937
|
+
return rows.map(rowToWaitState);
|
|
6938
|
+
}
|
|
6939
|
+
|
|
6940
|
+
/**
|
|
6941
|
+
* Distinct `eventName` values across pending event-mode waits. Used at boot
|
|
6942
|
+
* by the wait-bus subscription system to register one listener per event name.
|
|
6943
|
+
*/
|
|
6944
|
+
export function getPendingEventWaitNames(): string[] {
|
|
6945
|
+
const rows = getDb()
|
|
6946
|
+
.prepare<{ eventName: string }, []>(
|
|
6947
|
+
`SELECT DISTINCT eventName FROM wait_states
|
|
6948
|
+
WHERE status = 'pending' AND eventName IS NOT NULL`,
|
|
6949
|
+
)
|
|
6950
|
+
.all();
|
|
6951
|
+
return rows.map((r) => r.eventName);
|
|
6952
|
+
}
|
|
6953
|
+
|
|
6954
|
+
/**
|
|
6955
|
+
* Find pending event-mode waits matching `eventName`. Optional `runId` narrows
|
|
6956
|
+
* to a single run for run-scoped signals. The Phase 3 listener applies the
|
|
6957
|
+
* declarative/JS filter on top of this; the DB query is the cheap pre-filter.
|
|
6958
|
+
*/
|
|
6959
|
+
export function getPendingWaitsByEvent(eventName: string, runId?: string): WaitStateRow[] {
|
|
6960
|
+
if (runId !== undefined) {
|
|
6961
|
+
const rows = getDb()
|
|
6962
|
+
.prepare<WaitStateRowDb, [string, string]>(
|
|
6963
|
+
`SELECT * FROM wait_states
|
|
6964
|
+
WHERE status = 'pending' AND mode = 'event' AND eventName = ? AND workflowRunId = ?`,
|
|
6965
|
+
)
|
|
6966
|
+
.all(eventName, runId);
|
|
6967
|
+
return rows.map(rowToWaitState);
|
|
6968
|
+
}
|
|
6969
|
+
const rows = getDb()
|
|
6970
|
+
.prepare<WaitStateRowDb, [string]>(
|
|
6971
|
+
`SELECT * FROM wait_states
|
|
6972
|
+
WHERE status = 'pending' AND mode = 'event' AND eventName = ?`,
|
|
6973
|
+
)
|
|
6974
|
+
.all(eventName);
|
|
6975
|
+
return rows.map(rowToWaitState);
|
|
6976
|
+
}
|
|
6977
|
+
|
|
6978
|
+
/**
|
|
6979
|
+
* Atomic state transition: pending → fired|timeout. Returns `{updated: true}`
|
|
6980
|
+
* iff the caller won the race (UPDATE matched a pending row). Concurrent
|
|
6981
|
+
* callers see `{updated: false}` and should bail without further side effects.
|
|
6982
|
+
*/
|
|
6983
|
+
export function resolveWaitState(
|
|
6984
|
+
id: string,
|
|
6985
|
+
data: { status: Exclude<WaitStateStatus, "pending">; firedPayload?: unknown },
|
|
6986
|
+
): { updated: boolean; row: WaitStateRow | null } {
|
|
6987
|
+
const now = new Date().toISOString();
|
|
6988
|
+
const row = getDb()
|
|
6989
|
+
.prepare<WaitStateRowDb, [string, string | null, string, string, string]>(
|
|
6990
|
+
`UPDATE wait_states
|
|
6991
|
+
SET status = ?, firedPayload = ?, resolvedAt = ?, updatedAt = ?
|
|
6992
|
+
WHERE id = ? AND status = 'pending'
|
|
6993
|
+
RETURNING *`,
|
|
6994
|
+
)
|
|
6995
|
+
.get(
|
|
6996
|
+
data.status,
|
|
6997
|
+
data.firedPayload !== undefined ? JSON.stringify(data.firedPayload) : null,
|
|
6998
|
+
now,
|
|
6999
|
+
now,
|
|
7000
|
+
id,
|
|
7001
|
+
);
|
|
7002
|
+
return { updated: row !== null, row: row ? rowToWaitState(row) : null };
|
|
7003
|
+
}
|
|
7004
|
+
|
|
7005
|
+
export interface StuckWaitRun {
|
|
7006
|
+
runId: string;
|
|
7007
|
+
stepId: string;
|
|
7008
|
+
nodeId: string;
|
|
7009
|
+
workflowId: string;
|
|
7010
|
+
waitId: string;
|
|
7011
|
+
waitMode: string;
|
|
7012
|
+
waitStatus: string;
|
|
7013
|
+
wakeUpAt: string | null;
|
|
7014
|
+
expiresAt: string | null;
|
|
7015
|
+
firedPayload: string | null;
|
|
7016
|
+
}
|
|
7017
|
+
|
|
7018
|
+
/**
|
|
7019
|
+
* Recovery scan: workflow runs in `waiting` whose wait_state is either
|
|
7020
|
+
* (a) already non-pending — signal arrived / timeout fired while down and
|
|
7021
|
+
* the in-memory bus event was lost, OR
|
|
7022
|
+
* (b) still pending but overdue (`wakeUpAt`/`expiresAt` already past).
|
|
7023
|
+
*
|
|
7024
|
+
* Case (b) overlaps with the wait-poller's first tick after boot, but explicit
|
|
7025
|
+
* recovery avoids the up-to-5s startup latency window for stuck runs.
|
|
7026
|
+
*/
|
|
7027
|
+
export function getStuckWaitRuns(): StuckWaitRun[] {
|
|
7028
|
+
return getDb()
|
|
7029
|
+
.prepare<StuckWaitRun, []>(
|
|
7030
|
+
`SELECT
|
|
7031
|
+
wr.id as runId,
|
|
7032
|
+
wrs.id as stepId,
|
|
7033
|
+
wrs.nodeId,
|
|
7034
|
+
wr.workflowId,
|
|
7035
|
+
ws.id as waitId,
|
|
7036
|
+
ws.mode as waitMode,
|
|
7037
|
+
ws.status as waitStatus,
|
|
7038
|
+
ws.wakeUpAt as wakeUpAt,
|
|
7039
|
+
ws.expiresAt as expiresAt,
|
|
7040
|
+
ws.firedPayload as firedPayload
|
|
7041
|
+
FROM workflow_runs wr
|
|
7042
|
+
JOIN workflow_run_steps wrs ON wrs.runId = wr.id AND wrs.status = 'waiting' AND wrs.nodeType = 'wait'
|
|
7043
|
+
JOIN wait_states ws ON ws.workflowRunStepId = wrs.id
|
|
7044
|
+
WHERE wr.status = 'waiting'
|
|
7045
|
+
AND (
|
|
7046
|
+
ws.status IN ('fired', 'timeout')
|
|
7047
|
+
OR (
|
|
7048
|
+
ws.status = 'pending'
|
|
7049
|
+
AND (
|
|
7050
|
+
(ws.wakeUpAt IS NOT NULL
|
|
7051
|
+
AND ws.wakeUpAt <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
7052
|
+
OR
|
|
7053
|
+
(ws.expiresAt IS NOT NULL
|
|
7054
|
+
AND ws.expiresAt <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
7055
|
+
)
|
|
7056
|
+
)
|
|
7057
|
+
)`,
|
|
7058
|
+
)
|
|
7059
|
+
.all();
|
|
7060
|
+
}
|
|
7061
|
+
|
|
6784
7062
|
// ============================================================================
|
|
6785
7063
|
// Skills
|
|
6786
7064
|
// ============================================================================
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 049_wait_states.sql
|
|
2
|
+
-- Wait Node side table for the workflow engine
|
|
3
|
+
--
|
|
4
|
+
-- Mirrors the approval_requests precedent (020_approval_requests.sql) for the
|
|
5
|
+
-- async-pause-and-resume pattern. A `wait` workflow node pauses execution
|
|
6
|
+
-- either for a fixed duration (mode='time', wakeUpAt) or until an external
|
|
7
|
+
-- event arrives (mode='event', eventName + eventFilter, optional expiresAt).
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS wait_states (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
workflowRunId TEXT NOT NULL,
|
|
12
|
+
workflowRunStepId TEXT NOT NULL,
|
|
13
|
+
mode TEXT NOT NULL CHECK (mode IN ('time', 'event')),
|
|
14
|
+
wakeUpAt DATETIME, -- mode='time'; NULL for event mode
|
|
15
|
+
eventName TEXT, -- mode='event'
|
|
16
|
+
eventFilter JSONB, -- mode='event'; flat key/val match or arrow-fn body
|
|
17
|
+
expiresAt DATETIME, -- mode='event' optional timeout
|
|
18
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
19
|
+
CHECK (status IN ('pending', 'fired', 'timeout')),
|
|
20
|
+
firedPayload JSONB, -- payload that satisfied an event wait
|
|
21
|
+
resolvedAt DATETIME,
|
|
22
|
+
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
23
|
+
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_wait_states_step ON wait_states(workflowRunStepId);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_wait_states_run ON wait_states(workflowRunId);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_wait_states_wake ON wait_states(wakeUpAt) WHERE status = 'pending';
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_wait_states_expire ON wait_states(expiresAt) WHERE status = 'pending';
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_wait_states_event ON wait_states(eventName) WHERE status = 'pending';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- 050_wait_states_scope.sql
|
|
2
|
+
-- Add `scope` column to wait_states (Phase 3 of the wait-node plan).
|
|
3
|
+
--
|
|
4
|
+
-- The bus listener needs to know whether to enforce a run-id match between
|
|
5
|
+
-- the incoming payload (`_runId` or `workflowRunId`) and the wait_state's
|
|
6
|
+
-- `workflowRunId`. Storing it in a dedicated column keeps the matcher
|
|
7
|
+
-- O(1)-fast (no JSON.parse on every event) and surfaces it in DB queries.
|
|
8
|
+
--
|
|
9
|
+
-- - 'run' (default): listener enforces payload._runId === waitState.workflowRunId
|
|
10
|
+
-- (or payload.workflowRunId for built-in events from src/be/db.ts)
|
|
11
|
+
-- - 'global': listener skips the run-id check; any payload satisfying
|
|
12
|
+
-- the filter resolves the wait
|
|
13
|
+
--
|
|
14
|
+
-- Existing rows (all from Phase 2 — time mode only) get the default 'run'
|
|
15
|
+
-- which is harmless because time-mode rows never go through the bus listener.
|
|
16
|
+
|
|
17
|
+
ALTER TABLE wait_states
|
|
18
|
+
ADD COLUMN eventScope TEXT NOT NULL DEFAULT 'run'
|
|
19
|
+
CHECK (eventScope IN ('run', 'global'));
|
package/src/http/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { handleTasks } from "./tasks";
|
|
|
46
46
|
import { handleTrackers } from "./trackers";
|
|
47
47
|
import { getPathSegments, parseQueryParams, setCorsHeaders } from "./utils";
|
|
48
48
|
import { handleWebhooks } from "./webhooks";
|
|
49
|
+
import { handleWorkflowEvents } from "./workflow-events";
|
|
49
50
|
import { handleWorkflows } from "./workflows";
|
|
50
51
|
|
|
51
52
|
// Last-line-of-defense: never let a single bad request (e.g. a SQLITE_BUSY
|
|
@@ -129,6 +130,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
129
130
|
() => handlePricing(req, res, pathSegments, queryParams, myAgentId),
|
|
130
131
|
() => handleSchedules(req, res, pathSegments, queryParams, myAgentId),
|
|
131
132
|
() => handleWorkflows(req, res, pathSegments, queryParams, myAgentId),
|
|
133
|
+
() => handleWorkflowEvents(req, res, pathSegments, queryParams),
|
|
132
134
|
() => handleApprovalRequests(req, res, pathSegments, queryParams),
|
|
133
135
|
() => handleConfig(req, res, pathSegments, queryParams),
|
|
134
136
|
() => handleIntegrations(req, res, pathSegments),
|