@xyne/workflow-sdk 3.2.15 → 3.2.17
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/dist/agents/agent-step.d.ts.map +1 -1
- package/dist/agents/agent-step.js +3 -1
- package/dist/agents/agent-step.js.map +1 -1
- package/dist/authz/attributes.d.ts +41 -0
- package/dist/authz/attributes.d.ts.map +1 -0
- package/dist/authz/attributes.js +2 -0
- package/dist/authz/attributes.js.map +1 -0
- package/dist/authz/default.d.ts +31 -0
- package/dist/authz/default.d.ts.map +1 -0
- package/dist/authz/default.js +47 -0
- package/dist/authz/default.js.map +1 -0
- package/dist/authz/id-scope.d.ts +27 -0
- package/dist/authz/id-scope.d.ts.map +1 -0
- package/dist/authz/id-scope.js +24 -0
- package/dist/authz/id-scope.js.map +1 -0
- package/dist/authz/principal-field.d.ts +23 -0
- package/dist/authz/principal-field.d.ts.map +1 -0
- package/dist/authz/principal-field.js +34 -0
- package/dist/authz/principal-field.js.map +1 -0
- package/dist/authz/types.d.ts +129 -0
- package/dist/authz/types.d.ts.map +1 -0
- package/dist/authz/types.js +74 -0
- package/dist/authz/types.js.map +1 -0
- package/dist/client/types.d.ts +26 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/workflow-client.d.ts.map +1 -1
- package/dist/client/workflow-client.js +21 -2
- package/dist/client/workflow-client.js.map +1 -1
- package/dist/engine/workflow-executor.d.ts +1 -1
- package/dist/engine/workflow-executor.d.ts.map +1 -1
- package/dist/engine/workflow-executor.js +13 -2
- package/dist/engine/workflow-executor.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/persistence/in-memory-adapter.d.ts +40 -20
- package/dist/persistence/in-memory-adapter.d.ts.map +1 -1
- package/dist/persistence/in-memory-adapter.js +108 -38
- package/dist/persistence/in-memory-adapter.js.map +1 -1
- package/dist/persistence/types.d.ts +121 -20
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/router/types.d.ts +11 -8
- package/dist/router/types.d.ts.map +1 -1
- package/dist/router/workflow-router.d.ts +2 -2
- package/dist/router/workflow-router.d.ts.map +1 -1
- package/dist/router/workflow-router.js +179 -62
- package/dist/router/workflow-router.js.map +1 -1
- package/dist/runtime/types.d.ts +22 -2
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/workflow-runtime.d.ts +137 -43
- package/dist/runtime/workflow-runtime.d.ts.map +1 -1
- package/dist/runtime/workflow-runtime.js +288 -98
- package/dist/runtime/workflow-runtime.js.map +1 -1
- package/dist/steps/base-step.d.ts +19 -2
- package/dist/steps/base-step.d.ts.map +1 -1
- package/dist/steps/base-step.js.map +1 -1
- package/dist/storage/types.d.ts +14 -4
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/testing/authz-conformance.d.ts +90 -0
- package/dist/testing/authz-conformance.d.ts.map +1 -0
- package/dist/testing/authz-conformance.js +250 -0
- package/dist/testing/authz-conformance.js.map +1 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +6 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock-context.d.ts +4 -0
- package/dist/testing/mock-context.d.ts.map +1 -1
- package/dist/testing/mock-context.js +4 -1
- package/dist/testing/mock-context.js.map +1 -1
- package/dist/testing/mock-step-context.d.ts.map +1 -1
- package/dist/testing/mock-step-context.js +1 -0
- package/dist/testing/mock-step-context.js.map +1 -1
- package/dist/types/context.d.ts +22 -3
- package/dist/types/context.d.ts.map +1 -1
- package/package.json +9 -3
- package/dist/common/principal.d.ts +0 -45
- package/dist/common/principal.d.ts.map +0 -1
- package/dist/common/principal.js +0 -9
- package/dist/common/principal.js.map +0 -1
- package/dist/steps/builtin/transform.step.d.ts +0 -247
- package/dist/steps/builtin/transform.step.d.ts.map +0 -1
- package/dist/steps/builtin/transform.step.js +0 -135
- package/dist/steps/builtin/transform.step.js.map +0 -1
- package/dist/types/attachment.d.ts +0 -23
- package/dist/types/attachment.d.ts.map +0 -1
- package/dist/types/attachment.js +0 -2
- package/dist/types/attachment.js.map +0 -1
- package/dist/types/resume-payload.d.ts +0 -34
- package/dist/types/resume-payload.d.ts.map +0 -1
- package/dist/types/resume-payload.js +0 -12
- package/dist/types/resume-payload.js.map +0 -1
- package/dist/util/executable-check.d.ts +0 -42
- package/dist/util/executable-check.d.ts.map +0 -1
- package/dist/util/executable-check.js +0 -115
- package/dist/util/executable-check.js.map +0 -1
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { ConfigValidator } from '../engine/config-validator.js';
|
|
15
15
|
import { validateTriggerPayload } from '../common/validate-payload.js';
|
|
16
|
+
import { AccessDeniedError, ResourceNotFoundError } from '../authz/default.js';
|
|
16
17
|
import { EventTrigger } from '../triggers/event-trigger.js';
|
|
17
18
|
import { WebhookTrigger } from '../triggers/webhook-trigger.js';
|
|
18
19
|
import { CronTrigger } from '../triggers/cron-trigger.js';
|
|
@@ -61,6 +62,8 @@ function findStepConfigById(config, id) {
|
|
|
61
62
|
// ─── WorkflowRuntime ───
|
|
62
63
|
export class WorkflowRuntime {
|
|
63
64
|
persistence;
|
|
65
|
+
/** Authorization port — host-supplied, always. See {@link RuntimeOptions.authorizer}. */
|
|
66
|
+
authorizer;
|
|
64
67
|
queue;
|
|
65
68
|
scheduler;
|
|
66
69
|
services;
|
|
@@ -83,6 +86,10 @@ export class WorkflowRuntime {
|
|
|
83
86
|
aiBuilder;
|
|
84
87
|
constructor(opts) {
|
|
85
88
|
this.persistence = opts.persistence;
|
|
89
|
+
// No fallback and no cast: the authorizer and the adapter must agree on
|
|
90
|
+
// `TFilter`, and the only way to guarantee that is to make the host supply
|
|
91
|
+
// both. See {@link RuntimeOptions.authorizer}.
|
|
92
|
+
this.authorizer = opts.authorizer;
|
|
86
93
|
this.queue = opts.queue;
|
|
87
94
|
this.scheduler = opts.scheduler;
|
|
88
95
|
this.services = opts.services;
|
|
@@ -99,6 +106,86 @@ export class WorkflowRuntime {
|
|
|
99
106
|
this.defaultCronTimezone = config.defaultCronTimezone ?? 'Asia/Kolkata';
|
|
100
107
|
this.aiBuilder = config.aiBuilder;
|
|
101
108
|
}
|
|
109
|
+
// ─── Authorization primitives ───
|
|
110
|
+
//
|
|
111
|
+
// Every caller-facing operation goes through exactly one of these. There is no
|
|
112
|
+
// bypass: system-initiated work lives on `this.system` and never reaches here.
|
|
113
|
+
/**
|
|
114
|
+
* Guard an action on a ref the caller already has. Throws
|
|
115
|
+
* {@link AccessDeniedError} if denied; returns the full action set so callers
|
|
116
|
+
* can reuse it as the resource's `permittedActions` for the UI.
|
|
117
|
+
*/
|
|
118
|
+
async require(ctx, action, ref) {
|
|
119
|
+
const perms = await this.authorizer.permissions(ctx, ref);
|
|
120
|
+
if (!perms.includes(action))
|
|
121
|
+
throw new AccessDeniedError(action, ref);
|
|
122
|
+
return perms;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Fetch a workflow AND decide on it, atomically. This is the ONLY way a
|
|
126
|
+
* caller-facing op obtains a workflow — so "forgot to authorize" is not
|
|
127
|
+
* expressible, rather than merely discouraged.
|
|
128
|
+
*/
|
|
129
|
+
async authorizedWorkflow(ctx, id, action) {
|
|
130
|
+
const record = await this.persistence.getWorkflow(id);
|
|
131
|
+
if (!record)
|
|
132
|
+
throw new ResourceNotFoundError('Workflow', id);
|
|
133
|
+
const permittedActions = await this.require(ctx, action, this.workflowRef(record));
|
|
134
|
+
return { record, permittedActions };
|
|
135
|
+
}
|
|
136
|
+
/** Fetch a folder AND decide on it, atomically. See {@link authorizedWorkflow}. */
|
|
137
|
+
async authorizedFolder(ctx, id, action) {
|
|
138
|
+
const record = await this.persistence.getFolder(id);
|
|
139
|
+
if (!record)
|
|
140
|
+
throw new ResourceNotFoundError('Folder', id);
|
|
141
|
+
const permittedActions = await this.require(ctx, action, this.folderRef(record));
|
|
142
|
+
return { record, permittedActions };
|
|
143
|
+
}
|
|
144
|
+
/** The caller's list predicate for `type`, handed to the adapter verbatim. */
|
|
145
|
+
filterFor(ctx, type) {
|
|
146
|
+
return this.authorizer.visibleFilter(ctx, type);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The container a new resource would land in: the named folder, or `root`
|
|
150
|
+
* when there is no parent. Create is guarded against this — the container is
|
|
151
|
+
* the only thing that exists to ask about before the resource does.
|
|
152
|
+
*/
|
|
153
|
+
async containerRef(parentId) {
|
|
154
|
+
if (!parentId)
|
|
155
|
+
return { type: 'root' };
|
|
156
|
+
const folder = await this.persistence.getFolder(parentId);
|
|
157
|
+
if (!folder)
|
|
158
|
+
throw new ResourceNotFoundError('Folder', parentId);
|
|
159
|
+
return this.folderRef(folder);
|
|
160
|
+
}
|
|
161
|
+
/** Build a workflow ref — the record already carries typed `attributes`. */
|
|
162
|
+
workflowRef(wf) {
|
|
163
|
+
return { type: 'workflow', id: wf.id, record: wf };
|
|
164
|
+
}
|
|
165
|
+
/** Build a folder ref from the stored record — carries the folder's typed `attributes`. */
|
|
166
|
+
folderRef(folder) {
|
|
167
|
+
return { type: 'folder', id: folder.id, record: folder };
|
|
168
|
+
}
|
|
169
|
+
executionRef(exec) {
|
|
170
|
+
return { type: 'execution', id: exec.id, record: exec };
|
|
171
|
+
}
|
|
172
|
+
credentialRef(cred) {
|
|
173
|
+
return { type: 'credential', id: cred.id, record: cred };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Populate each list row's `permittedActions` in one authorizer round-trip
|
|
177
|
+
* (via `permissionsBatch`, falling back to per-item) so the UI gates inline
|
|
178
|
+
* row actions synchronously — no N+1 fetch, no optimistic buttons.
|
|
179
|
+
*/
|
|
180
|
+
async project(ctx, records, toRef) {
|
|
181
|
+
if (records.length === 0)
|
|
182
|
+
return records;
|
|
183
|
+
const refs = records.map(toRef);
|
|
184
|
+
const perms = this.authorizer.permissionsBatch
|
|
185
|
+
? await this.authorizer.permissionsBatch(ctx, refs)
|
|
186
|
+
: await Promise.all(refs.map((ref) => this.authorizer.permissions(ctx, ref)));
|
|
187
|
+
return records.map((r, i) => ({ ...r, permittedActions: perms[i] }));
|
|
188
|
+
}
|
|
102
189
|
// ─── Validation ───
|
|
103
190
|
/**
|
|
104
191
|
* Validate a workflow config before saving.
|
|
@@ -119,11 +206,18 @@ export class WorkflowRuntime {
|
|
|
119
206
|
* - CronTrigger: registers with the scheduler
|
|
120
207
|
* - EventTrigger / ManualTrigger: no activation needed (status change only)
|
|
121
208
|
*/
|
|
122
|
-
async activateWorkflow(workflowId) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
209
|
+
async activateWorkflow(ctx, workflowId) {
|
|
210
|
+
// Guard up front, before any external side-effects (webhook/scheduler).
|
|
211
|
+
const { record } = await this.authorizedWorkflow(ctx, workflowId, 'workflow:activate');
|
|
212
|
+
return this.activateInternal(record);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The activation work, already authorized. Used by the guarded
|
|
216
|
+
* {@link activateWorkflow} and by `createWorkflow` (whose create was itself
|
|
217
|
+
* authorized against the target folder).
|
|
218
|
+
*/
|
|
219
|
+
async activateInternal(workflow) {
|
|
220
|
+
const workflowId = workflow.id;
|
|
127
221
|
const config = parseWorkflowConfig(workflow.config);
|
|
128
222
|
if (!this.triggers.has(config.trigger.type)) {
|
|
129
223
|
throw new Error(`Trigger type "${config.trigger.type}" is not registered`);
|
|
@@ -152,11 +246,12 @@ export class WorkflowRuntime {
|
|
|
152
246
|
/**
|
|
153
247
|
* Deactivate a workflow's trigger.
|
|
154
248
|
*/
|
|
155
|
-
async deactivateWorkflow(workflowId) {
|
|
249
|
+
async deactivateWorkflow(ctx, workflowId) {
|
|
156
250
|
const workflow = await this.persistence.getWorkflow(workflowId);
|
|
157
251
|
if (!workflow) {
|
|
158
252
|
throw new Error(`Workflow ${workflowId} not found`);
|
|
159
253
|
}
|
|
254
|
+
await this.require(ctx, 'workflow:activate', this.workflowRef(workflow));
|
|
160
255
|
const config = parseWorkflowConfig(workflow.config);
|
|
161
256
|
if (!this.triggers.has(config.trigger.type))
|
|
162
257
|
return;
|
|
@@ -332,7 +427,9 @@ export class WorkflowRuntime {
|
|
|
332
427
|
const data = body && typeof body === 'object' && !Array.isArray(body)
|
|
333
428
|
? body
|
|
334
429
|
: { body };
|
|
335
|
-
|
|
430
|
+
// System path: the unguessable secret in the URL is the authorization —
|
|
431
|
+
// there is no caller to ask the authorizer about.
|
|
432
|
+
await this.resumeInternal(executionId, {
|
|
336
433
|
data: { action: 'approve', data },
|
|
337
434
|
});
|
|
338
435
|
return { status: 202, body: { received: true, executionId } };
|
|
@@ -349,11 +446,12 @@ export class WorkflowRuntime {
|
|
|
349
446
|
*
|
|
350
447
|
* Returns the execution ID.
|
|
351
448
|
*/
|
|
352
|
-
async triggerManual(workflowId, payload) {
|
|
449
|
+
async triggerManual(ctx, workflowId, payload) {
|
|
353
450
|
const workflow = await this.persistence.getWorkflow(workflowId);
|
|
354
451
|
if (!workflow) {
|
|
355
452
|
throw new Error(`Workflow ${workflowId} not found`);
|
|
356
453
|
}
|
|
454
|
+
await this.require(ctx, 'workflow:run', this.workflowRef(workflow));
|
|
357
455
|
const config = parseWorkflowConfig(workflow.config);
|
|
358
456
|
const triggerCfg = (config.trigger.config ?? {});
|
|
359
457
|
const inputSchema = triggerCfg['inputSchema'];
|
|
@@ -368,11 +466,21 @@ export class WorkflowRuntime {
|
|
|
368
466
|
return this.createAndEnqueue(workflow, triggerPayload);
|
|
369
467
|
}
|
|
370
468
|
/**
|
|
371
|
-
* Resume a paused execution.
|
|
372
|
-
*
|
|
373
|
-
* Stores the resume payload and re-enqueues the execution.
|
|
469
|
+
* Resume a paused execution. Guards `execution:approve`, then does the work.
|
|
374
470
|
*/
|
|
375
|
-
async resume(executionId, input) {
|
|
471
|
+
async resume(ctx, executionId, input) {
|
|
472
|
+
const exec = await this.persistence.getExecution(executionId);
|
|
473
|
+
if (!exec)
|
|
474
|
+
throw new ResourceNotFoundError('Execution', executionId);
|
|
475
|
+
await this.require(ctx, 'execution:approve', this.executionRef(exec));
|
|
476
|
+
return this.resumeInternal(executionId, input);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The resume work, already authorized. Private so the only ways in are the
|
|
480
|
+
* guarded {@link resume} or an explicit system entry point (a wait-callback,
|
|
481
|
+
* whose secret-in-path IS its authorization).
|
|
482
|
+
*/
|
|
483
|
+
async resumeInternal(executionId, input) {
|
|
376
484
|
const exec = await this.persistence.getExecution(executionId);
|
|
377
485
|
if (!exec) {
|
|
378
486
|
throw new Error(`Execution ${executionId} not found`);
|
|
@@ -447,27 +555,36 @@ export class WorkflowRuntime {
|
|
|
447
555
|
*
|
|
448
556
|
* Returns the new execution ID.
|
|
449
557
|
*/
|
|
450
|
-
async rerunFromStep(sourceExecutionId, fromStepId, options) {
|
|
558
|
+
async rerunFromStep(ctx, sourceExecutionId, fromStepId, options) {
|
|
559
|
+
const source = await this.persistence.getExecution(sourceExecutionId);
|
|
560
|
+
if (!source)
|
|
561
|
+
throw new Error(`Execution ${sourceExecutionId} not found`);
|
|
562
|
+
await this.require(ctx, 'execution:rerun', this.executionRef(source));
|
|
451
563
|
const newExecutionId = await this.executor.rerunFromStep(sourceExecutionId, fromStepId, options);
|
|
452
564
|
await this.queue.enqueue({ executionId: newExecutionId });
|
|
453
565
|
return newExecutionId;
|
|
454
566
|
}
|
|
455
567
|
// ─── CRUD (used by router) ───
|
|
456
568
|
/**
|
|
457
|
-
* List
|
|
569
|
+
* List the workflows visible to `ctx`. The host's filter is pushed *into*
|
|
570
|
+
* the adapter's query — the SDK never enumerates ids to post-filter.
|
|
458
571
|
*/
|
|
459
|
-
async listWorkflows(
|
|
460
|
-
|
|
572
|
+
async listWorkflows(ctx, page) {
|
|
573
|
+
const filter = await this.filterFor(ctx, 'workflow');
|
|
574
|
+
const records = await this.persistence.listWorkflows(filter, page);
|
|
575
|
+
return this.project(ctx, records, (wf) => this.workflowRef(wf));
|
|
461
576
|
}
|
|
462
|
-
/** Workflow counts for the
|
|
463
|
-
async workflowCounts(
|
|
464
|
-
return this.persistence.countWorkflowsByFolder(
|
|
577
|
+
/** Workflow counts for the visible set — total + per-folder, for the folder rail. */
|
|
578
|
+
async workflowCounts(ctx) {
|
|
579
|
+
return this.persistence.countWorkflowsByFolder(await this.filterFor(ctx, 'workflow'));
|
|
465
580
|
}
|
|
466
581
|
/**
|
|
467
|
-
* Get a single workflow by ID.
|
|
582
|
+
* Get a single workflow by ID. Guards `workflow:read` and attaches the
|
|
583
|
+
* caller's `permittedActions` for the UI.
|
|
468
584
|
*/
|
|
469
|
-
async getWorkflow(workflowId) {
|
|
470
|
-
|
|
585
|
+
async getWorkflow(ctx, workflowId) {
|
|
586
|
+
const { record, permittedActions } = await this.authorizedWorkflow(ctx, workflowId, 'workflow:read');
|
|
587
|
+
return { ...record, permittedActions };
|
|
471
588
|
}
|
|
472
589
|
/**
|
|
473
590
|
* Create a new workflow definition.
|
|
@@ -475,13 +592,12 @@ export class WorkflowRuntime {
|
|
|
475
592
|
* Validates the config, persists it, and optionally activates the trigger.
|
|
476
593
|
* Returns the created WorkflowRecord.
|
|
477
594
|
*/
|
|
478
|
-
async createWorkflow(input) {
|
|
595
|
+
async createWorkflow(ctx, input) {
|
|
596
|
+
// Create is an action on the *container*: the folder is the thing that
|
|
597
|
+
// exists, so this same decision is what the UI projects onto its "New
|
|
598
|
+
// workflow" button. Also proves the folder exists — no separate lookup.
|
|
599
|
+
await this.authorizedFolder(ctx, input.folderId, 'workflow:create');
|
|
479
600
|
const validation = this.validateConfig(input.config);
|
|
480
|
-
// Every workflow must live in an existing folder.
|
|
481
|
-
const folders = await this.persistence.listFolders(input.metadata ?? {});
|
|
482
|
-
if (!folders.some((f) => f.id === input.folderId)) {
|
|
483
|
-
throw new Error(`Folder ${input.folderId} not found`);
|
|
484
|
-
}
|
|
485
601
|
const metadata = { ...input.metadata, name: input.name };
|
|
486
602
|
const id = await this.persistence.createWorkflow({
|
|
487
603
|
status: 'ACTIVE',
|
|
@@ -490,13 +606,15 @@ export class WorkflowRuntime {
|
|
|
490
606
|
eventType: input.config.trigger.type,
|
|
491
607
|
summary: input.summary ?? null,
|
|
492
608
|
folderId: input.folderId,
|
|
609
|
+
attributes: input.attributes,
|
|
493
610
|
});
|
|
494
611
|
const record = await this.persistence.getWorkflow(id);
|
|
495
612
|
if (!record)
|
|
496
613
|
throw new Error('Failed to read created workflow');
|
|
497
614
|
if (validation.valid) {
|
|
498
615
|
try {
|
|
499
|
-
|
|
616
|
+
// Internal post-create activation — already authorized by the create above.
|
|
617
|
+
await this.activateInternal(record);
|
|
500
618
|
}
|
|
501
619
|
catch {
|
|
502
620
|
// Non-fatal — workflow is created but trigger may need manual activation
|
|
@@ -511,20 +629,16 @@ export class WorkflowRuntime {
|
|
|
511
629
|
* Accepts partial updates: config, metadata, or both.
|
|
512
630
|
* If config changes, validates it and updates the trigger type.
|
|
513
631
|
*/
|
|
514
|
-
async updateWorkflow(workflowId, input) {
|
|
515
|
-
const existing = await this.
|
|
516
|
-
if (!existing) {
|
|
517
|
-
throw new Error(`Workflow ${workflowId} not found`);
|
|
518
|
-
}
|
|
632
|
+
async updateWorkflow(ctx, workflowId, input) {
|
|
633
|
+
const { record: existing } = await this.authorizedWorkflow(ctx, workflowId, 'workflow:update');
|
|
519
634
|
const update = {};
|
|
520
635
|
if (input.summary !== undefined)
|
|
521
636
|
update.summary = input.summary;
|
|
522
|
-
// Move to another folder —
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
637
|
+
// Move to another folder — needs `workflow:create` on the destination, the
|
|
638
|
+
// same decision that gates creating one there. `authorizedFolder` also
|
|
639
|
+
// proves the folder exists.
|
|
640
|
+
if (input.folderId !== undefined && input.folderId !== existing.folderId) {
|
|
641
|
+
await this.authorizedFolder(ctx, input.folderId, 'workflow:create');
|
|
528
642
|
update.folderId = input.folderId;
|
|
529
643
|
}
|
|
530
644
|
// Update config if provided.
|
|
@@ -552,12 +666,35 @@ export class WorkflowRuntime {
|
|
|
552
666
|
return updated;
|
|
553
667
|
}
|
|
554
668
|
// ─── Folders ───
|
|
555
|
-
/** List folders
|
|
556
|
-
async listFolders(
|
|
557
|
-
|
|
669
|
+
/** List folders the caller may see. */
|
|
670
|
+
async listFolders(ctx) {
|
|
671
|
+
const records = await this.persistence.listFolders(await this.filterFor(ctx, 'folder'));
|
|
672
|
+
return this.project(ctx, records, (f) => this.folderRef(f));
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Get a single folder by id. Guards `folder:read` and attaches the caller's
|
|
676
|
+
* `permittedActions` — which include the container actions
|
|
677
|
+
* (`workflow:create` / `folder:create`), so the UI gates this folder's "New…"
|
|
678
|
+
* menu off the same decision the runtime will enforce.
|
|
679
|
+
*/
|
|
680
|
+
async getFolder(ctx, folderId) {
|
|
681
|
+
const { record, permittedActions } = await this.authorizedFolder(ctx, folderId, 'folder:read');
|
|
682
|
+
return { ...record, permittedActions };
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* What may the caller do at the top level — create a root folder, manage
|
|
686
|
+
* credentials? Container actions on a resource that has no instance, so the
|
|
687
|
+
* client can gate its top-level "New…" buttons without inventing a resource.
|
|
688
|
+
*/
|
|
689
|
+
async capabilities(ctx) {
|
|
690
|
+
return this.authorizer.permissions(ctx, { type: 'root' });
|
|
558
691
|
}
|
|
559
692
|
/** Create a folder for organizing workflows. */
|
|
560
|
-
async createFolder(input) {
|
|
693
|
+
async createFolder(ctx, input) {
|
|
694
|
+
// Create is an action on the container: a nested folder is gated by its
|
|
695
|
+
// parent, a top-level folder by `root`. Both are things the client can ask
|
|
696
|
+
// about before rendering "New folder".
|
|
697
|
+
await this.require(ctx, 'folder:create', await this.containerRef(input.parentId));
|
|
561
698
|
const name = input.name.trim();
|
|
562
699
|
if (!name)
|
|
563
700
|
throw new Error('Folder name is required');
|
|
@@ -565,21 +702,25 @@ export class WorkflowRuntime {
|
|
|
565
702
|
name,
|
|
566
703
|
metadata: JSON.stringify(input.metadata ?? {}),
|
|
567
704
|
parentId: input.parentId ?? null,
|
|
705
|
+
attributes: input.attributes,
|
|
568
706
|
});
|
|
569
|
-
const
|
|
570
|
-
const created = folders.find((f) => f.id === id);
|
|
707
|
+
const created = await this.persistence.getFolder(id);
|
|
571
708
|
if (!created)
|
|
572
709
|
throw new Error('Failed to read created folder');
|
|
573
710
|
return created;
|
|
574
711
|
}
|
|
575
|
-
/**
|
|
576
|
-
async moveBatch(
|
|
712
|
+
/** Reorganize folders and workflows in one batch (drag-and-drop). */
|
|
713
|
+
async moveBatch(ctx, input) {
|
|
714
|
+
// Each item is authorized individually by the op it delegates to, which
|
|
715
|
+
// guards both ends of the move: `*:update` on the thing moved and
|
|
716
|
+
// `*:create` on the destination container.
|
|
577
717
|
await Promise.all([
|
|
578
|
-
...input.folders.map((f) => this.updateFolder(f.id, { parentId: f.parentId })),
|
|
579
|
-
...input.workflows.map((w) => this.updateWorkflow(w.id, { folderId: w.folderId
|
|
718
|
+
...input.folders.map((f) => this.updateFolder(ctx, f.id, { parentId: f.parentId })),
|
|
719
|
+
...input.workflows.map((w) => this.updateWorkflow(ctx, w.id, { folderId: w.folderId })),
|
|
580
720
|
]);
|
|
581
721
|
}
|
|
582
|
-
async updateFolder(id, input) {
|
|
722
|
+
async updateFolder(ctx, id, input) {
|
|
723
|
+
await this.authorizedFolder(ctx, id, 'folder:update');
|
|
583
724
|
if (input.name !== undefined && !input.name.trim()) {
|
|
584
725
|
throw new Error('Folder name cannot be empty');
|
|
585
726
|
}
|
|
@@ -588,10 +729,14 @@ export class WorkflowRuntime {
|
|
|
588
729
|
data.name = input.name.trim();
|
|
589
730
|
if ('parentId' in input) {
|
|
590
731
|
const newParent = input.parentId ?? null;
|
|
732
|
+
// Reparenting adds a child to the destination, so it needs the same
|
|
733
|
+
// permission as creating one there — otherwise `folder:create` gates the
|
|
734
|
+
// "New folder" button and a drag-and-drop walks straight around it.
|
|
735
|
+
await this.require(ctx, 'folder:create', await this.containerRef(newParent));
|
|
591
736
|
// Moving a folder into itself or any of its own descendants creates a
|
|
592
737
|
// cycle — the folder would disappear from all reachable tree paths.
|
|
593
738
|
if (newParent !== null) {
|
|
594
|
-
const allFolders = await this.persistence.
|
|
739
|
+
const allFolders = await this.persistence.listAllFolders();
|
|
595
740
|
const descendants = new Set([id]);
|
|
596
741
|
const queue = [id];
|
|
597
742
|
while (queue.length > 0) {
|
|
@@ -615,9 +760,12 @@ export class WorkflowRuntime {
|
|
|
615
760
|
* Delete a folder. Blocked while it still holds workflows — every workflow
|
|
616
761
|
* must belong to a folder, so callers must move/remove its members first.
|
|
617
762
|
*/
|
|
618
|
-
async deleteFolder(
|
|
619
|
-
|
|
620
|
-
|
|
763
|
+
async deleteFolder(ctx, id) {
|
|
764
|
+
await this.authorizedFolder(ctx, id, 'folder:delete');
|
|
765
|
+
// Unfiltered on purpose: emptiness is a property of the folder, not of what
|
|
766
|
+
// this caller can see. Counting only visible workflows would let someone
|
|
767
|
+
// delete a folder out from under workflows they have no access to.
|
|
768
|
+
if ((await this.persistence.countWorkflowsInFolder(id)) > 0) {
|
|
621
769
|
throw new Error('Folder not empty');
|
|
622
770
|
}
|
|
623
771
|
await this.persistence.deleteFolder(id);
|
|
@@ -625,48 +773,61 @@ export class WorkflowRuntime {
|
|
|
625
773
|
/**
|
|
626
774
|
* Top N recurring error messages across failed executions in a time window.
|
|
627
775
|
*/
|
|
628
|
-
async getTopErrors(params) {
|
|
776
|
+
async getTopErrors(ctx, params) {
|
|
629
777
|
if (typeof this.persistence.getTopErrors !== 'function')
|
|
630
778
|
return [];
|
|
631
|
-
|
|
779
|
+
// Derived from executions, so it carries the same visibility as them.
|
|
780
|
+
return this.persistence.getTopErrors(await this.filterFor(ctx, 'workflow'), params);
|
|
632
781
|
}
|
|
633
782
|
/**
|
|
634
783
|
* Full analytics summary computed server-side — replaces fetching all executions on the client.
|
|
635
784
|
*/
|
|
636
|
-
async getAnalyticsSummary(params) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
785
|
+
async getAnalyticsSummary(ctx, params) {
|
|
786
|
+
const empty = {
|
|
787
|
+
totalRuns: 0,
|
|
788
|
+
successRate: 0,
|
|
789
|
+
byStatus: {},
|
|
790
|
+
runsByDay: [],
|
|
791
|
+
byWorkflow: [],
|
|
792
|
+
recentFailures: [],
|
|
793
|
+
activeWorkflows: 0,
|
|
794
|
+
totalWorkflows: 0,
|
|
795
|
+
};
|
|
796
|
+
if (typeof this.persistence.getAnalyticsSummary !== 'function')
|
|
797
|
+
return empty;
|
|
798
|
+
// Analytics is derived from workflows — scope it to the caller's visible
|
|
799
|
+
// workflows via the authorizer, exactly like listExecutions.
|
|
800
|
+
return this.persistence.getAnalyticsSummary(await this.filterFor(ctx, 'workflow'), params);
|
|
650
801
|
}
|
|
651
802
|
/**
|
|
652
803
|
* Per-step progress events (agent thinking / tool calls / logs) for the run
|
|
653
804
|
* view's activity timeline. Delegates to persistence.
|
|
654
805
|
*/
|
|
655
|
-
async getStepEvents(executionId, stepName, after) {
|
|
806
|
+
async getStepEvents(ctx, executionId, stepName, after) {
|
|
807
|
+
const exec = await this.persistence.getExecution(executionId);
|
|
808
|
+
if (exec)
|
|
809
|
+
await this.require(ctx, 'execution:read', this.executionRef(exec));
|
|
656
810
|
return this.persistence.getStepEvents(executionId, stepName, after);
|
|
657
811
|
}
|
|
658
812
|
/**
|
|
659
|
-
* List executions with optional filters and cursor pagination.
|
|
813
|
+
* List executions with optional filters and cursor pagination. Scoped to the
|
|
814
|
+
* caller's visible workflows (an execution is visible iff its workflow is).
|
|
660
815
|
*/
|
|
661
|
-
async listExecutions(params) {
|
|
662
|
-
|
|
816
|
+
async listExecutions(ctx, params) {
|
|
817
|
+
// Executions inherit their workflow's visibility — the adapter joins the
|
|
818
|
+
// workflow filter rather than us enumerating workflow ids first.
|
|
819
|
+
return this.persistence.listExecutions(await this.filterFor(ctx, 'workflow'), params);
|
|
663
820
|
}
|
|
664
821
|
/**
|
|
665
822
|
* Lightweight list of executions waiting for approval — only the fields
|
|
666
823
|
* needed to render the approval queue without N individual fetches.
|
|
667
824
|
*/
|
|
668
|
-
async listPendingApprovals(limit) {
|
|
669
|
-
|
|
825
|
+
async listPendingApprovals(ctx, limit) {
|
|
826
|
+
// Visible-workflow scoped for now: any caller who can see the workflow sees
|
|
827
|
+
// its pending gates. Narrowing this to the step's assignee is P3, and lands
|
|
828
|
+
// as `visibleFilter(ctx, 'approval')` — this stays the workflow filter until
|
|
829
|
+
// approval steps actually carry an assignee.
|
|
830
|
+
const { items } = await this.persistence.listExecutions(await this.filterFor(ctx, 'workflow'), { status: 'EXTERNAL_WAIT', ...(limit !== undefined ? { limit } : {}) });
|
|
670
831
|
const results = await Promise.all(items.map(async (exec) => {
|
|
671
832
|
const steps = await this.persistence.getStepRows(exec.id);
|
|
672
833
|
// Prefer the resumable leaf gate (non-control); fall back to any wait.
|
|
@@ -717,13 +878,14 @@ export class WorkflowRuntime {
|
|
|
717
878
|
/**
|
|
718
879
|
* Get execution detail including step records.
|
|
719
880
|
*/
|
|
720
|
-
async getExecutionDetail(executionId) {
|
|
881
|
+
async getExecutionDetail(ctx, executionId) {
|
|
721
882
|
const exec = await this.persistence.getExecution(executionId);
|
|
722
883
|
if (!exec)
|
|
723
884
|
return null;
|
|
885
|
+
const perms = await this.require(ctx, 'execution:read', this.executionRef(exec));
|
|
724
886
|
const stepRows = await this.persistence.getStepRows(executionId);
|
|
725
887
|
// Try to attach the workflow config snapshot
|
|
726
|
-
const result = { execution: exec, steps: stepRows };
|
|
888
|
+
const result = { execution: exec, steps: stepRows, permittedActions: perms };
|
|
727
889
|
const workflow = await this.persistence.getWorkflow(exec.workflowId);
|
|
728
890
|
if (workflow) {
|
|
729
891
|
result.summary = workflow.summary;
|
|
@@ -746,9 +908,9 @@ export class WorkflowRuntime {
|
|
|
746
908
|
try {
|
|
747
909
|
const state = await this.persistence.getExecutionState(executionId);
|
|
748
910
|
if (state?.context) {
|
|
749
|
-
const
|
|
750
|
-
if (
|
|
751
|
-
result.triggerData =
|
|
911
|
+
const wfCtx = JSON.parse(state.context);
|
|
912
|
+
if (wfCtx.trigger) {
|
|
913
|
+
result.triggerData = wfCtx.trigger;
|
|
752
914
|
}
|
|
753
915
|
}
|
|
754
916
|
}
|
|
@@ -760,11 +922,12 @@ export class WorkflowRuntime {
|
|
|
760
922
|
/**
|
|
761
923
|
* Cancel a running or paused execution.
|
|
762
924
|
*/
|
|
763
|
-
async cancelExecution(executionId) {
|
|
925
|
+
async cancelExecution(ctx, executionId) {
|
|
764
926
|
const exec = await this.persistence.getExecution(executionId);
|
|
765
927
|
if (!exec) {
|
|
766
928
|
throw new Error(`Execution ${executionId} not found`);
|
|
767
929
|
}
|
|
930
|
+
await this.require(ctx, 'execution:cancel', this.executionRef(exec));
|
|
768
931
|
if (exec.status === 'COMPLETED' || exec.status === 'FAILED' || exec.status === 'CANCELLED') {
|
|
769
932
|
throw new Error(`Execution ${executionId} is already terminal (status: ${exec.status})`);
|
|
770
933
|
}
|
|
@@ -773,23 +936,43 @@ export class WorkflowRuntime {
|
|
|
773
936
|
this.log.info(`execution ${executionId} cancelled`);
|
|
774
937
|
}
|
|
775
938
|
/**
|
|
776
|
-
* List credentials
|
|
939
|
+
* List credentials the caller may see — filtered by the authorizer, redacted
|
|
940
|
+
* (never secret data).
|
|
777
941
|
*/
|
|
778
|
-
async listCredentials(
|
|
779
|
-
|
|
942
|
+
async listCredentials(ctx) {
|
|
943
|
+
const creds = await this.persistence.listCredentials(await this.filterFor(ctx, 'credential'));
|
|
944
|
+
return creds.map((c) => ({ id: c.id, name: c.name, type: c.type }));
|
|
780
945
|
}
|
|
781
|
-
/**
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
946
|
+
/** Fetch a single credential's metadata by id (guarded `credential:read`; no secret). */
|
|
947
|
+
async getCredential(ctx, id) {
|
|
948
|
+
const cred = await this.persistence.getCredential(id);
|
|
949
|
+
if (!cred)
|
|
950
|
+
return null;
|
|
951
|
+
const perms = await this.require(ctx, 'credential:read', this.credentialRef(cred));
|
|
952
|
+
return { ...cred, permittedActions: perms };
|
|
787
953
|
}
|
|
788
954
|
/**
|
|
789
|
-
*
|
|
955
|
+
* Create a credential. Tenant/ownership ride typed `attributes` (via
|
|
956
|
+
* {@link Register}), stamped and stored exactly like a workflow's.
|
|
790
957
|
*/
|
|
791
|
-
async
|
|
792
|
-
|
|
958
|
+
async createCredential(ctx, input) {
|
|
959
|
+
// Credentials have no folder — `root` is their container.
|
|
960
|
+
await this.require(ctx, 'credential:manage', { type: 'root' });
|
|
961
|
+
const id = await this.persistence.createCredential({
|
|
962
|
+
name: input.name,
|
|
963
|
+
type: input.type,
|
|
964
|
+
data: input.data,
|
|
965
|
+
attributes: input.attributes,
|
|
966
|
+
});
|
|
967
|
+
return { id, name: input.name, type: input.type };
|
|
968
|
+
}
|
|
969
|
+
/** Delete a credential by id — guarded by `credential:manage` on the stored row. */
|
|
970
|
+
async deleteCredential(ctx, id) {
|
|
971
|
+
const cred = await this.persistence.getCredential(id);
|
|
972
|
+
if (!cred)
|
|
973
|
+
throw new Error(`Credential ${id} not found`);
|
|
974
|
+
await this.require(ctx, 'credential:manage', this.credentialRef(cred));
|
|
975
|
+
await this.persistence.deleteCredential(id);
|
|
793
976
|
}
|
|
794
977
|
// ─── Job Processing ───
|
|
795
978
|
/**
|
|
@@ -915,6 +1098,10 @@ export class WorkflowRuntime {
|
|
|
915
1098
|
const initialContext = {
|
|
916
1099
|
workflow: {
|
|
917
1100
|
id: workflow.id,
|
|
1101
|
+
// The execution's tenant, carried by the resource. The executor
|
|
1102
|
+
// re-hydrates this from the record on every run, so this is a seed
|
|
1103
|
+
// rather than the authority.
|
|
1104
|
+
attributes: workflow.attributes,
|
|
918
1105
|
metadata,
|
|
919
1106
|
},
|
|
920
1107
|
trigger: triggerPayload,
|
|
@@ -930,11 +1117,14 @@ export class WorkflowRuntime {
|
|
|
930
1117
|
return execId;
|
|
931
1118
|
}
|
|
932
1119
|
async resolveCredentialsForTrigger(workflow) {
|
|
933
|
-
|
|
934
|
-
|
|
1120
|
+
// System path: no caller exists at execution time, so the scope is carried
|
|
1121
|
+
// by the resource — the workflow's own typed `attributes` say which tenant's
|
|
1122
|
+
// credentials it may resolve. Not an ACL decision; there is nobody to ask about.
|
|
1123
|
+
const scope = workflow.attributes;
|
|
1124
|
+
const creds = await this.persistence.listTenantCredentials(scope);
|
|
935
1125
|
const resolved = {};
|
|
936
1126
|
for (const cred of creds) {
|
|
937
|
-
const full = await this.persistence.resolveCredential(
|
|
1127
|
+
const full = await this.persistence.resolveCredential(scope, cred.name);
|
|
938
1128
|
if (full) {
|
|
939
1129
|
resolved[cred.name] = full;
|
|
940
1130
|
}
|