@sanoma-ai/workflows 0.9.0 → 0.10.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/README.md +169 -52
- package/dist/apply-steps.js +5 -2
- package/dist/apply-steps.js.map +1 -1
- package/dist/apply.d.ts +3 -0
- package/dist/apply.js +35 -21
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +1 -0
- package/dist/approvals.js +6 -1
- package/dist/approvals.js.map +1 -1
- package/dist/apps.d.ts +7 -24
- package/dist/apps.js +51 -67
- package/dist/apps.js.map +1 -1
- package/dist/ast.d.ts +9 -7
- package/dist/ast.js +2 -0
- package/dist/ast.js.map +1 -1
- package/dist/authentication.d.ts +24 -0
- package/dist/authentication.js +68 -0
- package/dist/authentication.js.map +1 -0
- package/dist/call-workflow.d.ts +5 -0
- package/dist/call-workflow.js +4 -3
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +34 -7
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +14 -1
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.js +7 -5
- package/dist/change-steps.js.map +1 -1
- package/dist/change.d.ts +7 -0
- package/dist/change.js +7 -0
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +10 -6
- package/dist/client.js +96 -43
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +7 -0
- package/dist/collection.js +5 -2
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +14 -3
- package/dist/config.js +55 -14
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +168 -0
- package/dist/connection.js +327 -0
- package/dist/connection.js.map +1 -0
- package/dist/credentials.d.ts +7 -53
- package/dist/credentials.js +84 -247
- package/dist/credentials.js.map +1 -1
- package/dist/cucumber.d.ts +4 -2
- package/dist/cucumber.js +8 -3
- package/dist/cucumber.js.map +1 -1
- package/dist/db.js +21 -2
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js.map +1 -1
- package/dist/definitions.d.ts +20 -0
- package/dist/definitions.js +16 -0
- package/dist/definitions.js.map +1 -0
- package/dist/deployment-key.d.ts +2 -0
- package/dist/deployment-key.js +22 -0
- package/dist/deployment-key.js.map +1 -0
- package/dist/describe.d.ts +9 -177
- package/dist/describe.js +71 -34
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +532 -0
- package/dist/description.js +381 -0
- package/dist/description.js.map +1 -0
- package/dist/direct.js +3 -0
- package/dist/direct.js.map +1 -1
- package/dist/drift.d.ts +1 -1
- package/dist/drift.js +1 -1
- package/dist/drift.js.map +1 -1
- package/dist/env.d.ts +2 -0
- package/dist/env.js +2 -2
- package/dist/env.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +2 -0
- package/dist/ledger.js +1 -1
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +0 -9
- package/dist/lint.js +1 -16
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +74 -0
- package/dist/load.js +357 -0
- package/dist/load.js.map +1 -0
- package/dist/lockfile.d.ts +2 -0
- package/dist/lockfile.js +8 -3
- package/dist/lockfile.js.map +1 -1
- package/dist/op.d.ts +10 -1
- package/dist/op.js +1 -1
- package/dist/op.js.map +1 -1
- package/dist/outline.js +13 -4
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +21 -3
- package/dist/paths.js.map +1 -1
- package/dist/plugin.d.ts +0 -11
- package/dist/plugin.js +3 -18
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +27 -4
- package/dist/resource.js +49 -4
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +33 -76
- package/dist/resources.js +219 -227
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +2 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +1 -1
- package/dist/sandbox.js +7 -3
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +7 -0
- package/dist/scenario.js +33 -3
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.js +7 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.js +8 -0
- package/dist/schema.js.map +1 -1
- package/dist/secret-store-check.d.ts +11 -0
- package/dist/secret-store-check.js +76 -0
- package/dist/secret-store-check.js.map +1 -0
- package/dist/secret-store.d.ts +106 -0
- package/dist/secret-store.js +228 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/shared.d.ts +17 -11
- package/dist/shared.js +34 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +4 -93
- package/dist/sites.js +61 -84
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +6 -0
- package/dist/source.js +13 -0
- package/dist/source.js.map +1 -1
- package/dist/steps.d.ts +3 -1
- package/dist/steps.js.map +1 -1
- package/dist/version.d.ts +2 -1
- package/dist/version.js +2 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.js +15 -6
- package/dist/wakeup-driver.js.map +1 -1
- package/dist/worker.js +12 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +53 -14
- package/dist/writer.js +113 -43
- package/dist/writer.js.map +1 -1
- package/oxlint.json +0 -5
- package/package.json +6 -2
- package/src/apply-steps.ts +5 -2
- package/src/apply.ts +70 -29
- package/src/approvals.ts +7 -1
- package/src/apps.ts +54 -85
- package/src/ast.ts +6 -6
- package/src/authentication.ts +74 -0
- package/src/call-workflow.ts +5 -4
- package/src/call.ts +53 -9
- package/src/cedar.ts +14 -1
- package/src/change-steps.ts +7 -4
- package/src/change.ts +10 -0
- package/src/client.ts +108 -41
- package/src/collection.ts +10 -2
- package/src/config.ts +72 -13
- package/src/connection.ts +446 -0
- package/src/credentials.ts +94 -281
- package/src/cucumber.ts +16 -3
- package/src/db.ts +21 -2
- package/src/define.ts +7 -2
- package/src/definitions.ts +31 -0
- package/src/deployment-key.ts +23 -0
- package/src/describe.ts +102 -230
- package/src/description.ts +426 -0
- package/src/direct.ts +3 -0
- package/src/drift.ts +10 -2
- package/src/env.ts +4 -2
- package/src/index.ts +42 -1
- package/src/ledger.ts +3 -1
- package/src/lint.ts +1 -16
- package/src/load.ts +437 -0
- package/src/lockfile.ts +10 -3
- package/src/op.ts +10 -1
- package/src/outline.ts +18 -5
- package/src/paths.ts +24 -2
- package/src/plugin.ts +3 -24
- package/src/policy.ts +2 -0
- package/src/resource.ts +67 -6
- package/src/resources.ts +246 -301
- package/src/run.ts +2 -0
- package/src/sandbox.ts +11 -3
- package/src/scenario.ts +45 -3
- package/src/scenarios.ts +7 -1
- package/src/schema.ts +10 -0
- package/src/secret-store-check.ts +93 -0
- package/src/secret-store.ts +314 -0
- package/src/shared.ts +35 -10
- package/src/sites.ts +70 -180
- package/src/source.ts +14 -0
- package/src/steps.ts +3 -1
- package/src/version.ts +2 -1
- package/src/wakeup-driver.ts +22 -6
- package/src/worker.ts +12 -8
- package/src/writer.ts +145 -56
- package/dist/datafile.d.ts +0 -97
- package/dist/datafile.js +0 -479
- package/dist/datafile.js.map +0 -1
- package/src/datafile.ts +0 -601
package/src/call-workflow.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionRefSchema, type ConnectionRef } from "./connection.ts";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
4
|
import { isDirect } from "./direct.ts";
|
|
@@ -12,10 +13,10 @@ export { CALL_WORKFLOW } from "./shared.ts";
|
|
|
12
13
|
// held to the same lint: the call goes through `ctx`, under the policy, in the ledger.
|
|
13
14
|
|
|
14
15
|
/** An operation on `ctx`. */
|
|
15
|
-
type OpCall = (input: unknown) => Promise<unknown>;
|
|
16
|
+
type OpCall = (input: unknown, options?: { connection?: ConnectionRef }) => Promise<unknown>;
|
|
16
17
|
|
|
17
18
|
/** The call workflow's input: the operation's id, and its input. */
|
|
18
|
-
export const CallInput = z.object({ op: z.string(), input: z.unknown() });
|
|
19
|
+
export const CallInput = z.object({ op: z.string(), input: z.unknown(), connection: connectionRefSchema.optional() });
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* The call workflow for a config: it `uses` every operation that may be called directly and has
|
|
@@ -43,10 +44,10 @@ export function callWorkflow(
|
|
|
43
44
|
// Four segments for an app's collection: `data.<app>.<collection>.<op>`.
|
|
44
45
|
const [vendor = "", a = "", b = "", c] = input.op.split(".");
|
|
45
46
|
return c === undefined
|
|
46
|
-
? ctx[vendor]![a]![b]!(input.input)
|
|
47
|
+
? ctx[vendor]![a]![b]!(input.input, { connection: input.connection })
|
|
47
48
|
: (ctx as unknown as Record<string, Record<string, Record<string, Record<string, OpCall>>>>)[vendor]![a]![b]![
|
|
48
49
|
c
|
|
49
|
-
]!(input.input);
|
|
50
|
+
]!(input.input, { connection: input.connection });
|
|
50
51
|
},
|
|
51
52
|
});
|
|
52
53
|
}
|
package/src/call.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConnectionRef, ConnectionSnapshot } from "./connection.ts";
|
|
1
2
|
import { DBOS, DBOSWorkflowConflictError, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
2
3
|
import { awaitApproval, type CheckedApproval } from "./approvals.ts";
|
|
3
4
|
import { offsetOf } from "./ast.ts";
|
|
@@ -161,8 +162,10 @@ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
|
|
|
161
162
|
.split(".")
|
|
162
163
|
.slice(0, -1)
|
|
163
164
|
.reduce<Record<string, any>>((at, key) => (at[key] ??= {}), members);
|
|
164
|
-
group[op.name] = placed(op.id, (node, input) =>
|
|
165
|
-
serial(run, () =>
|
|
165
|
+
group[op.name] = placed(op.id, (node, input, options?: { connection?: ConnectionRef }) =>
|
|
166
|
+
serial(run, () =>
|
|
167
|
+
callOp(run, op.id, input, { node: node.path, name: named(node) ?? op.id }, options?.connection),
|
|
168
|
+
),
|
|
166
169
|
);
|
|
167
170
|
}
|
|
168
171
|
members.runId = run.id;
|
|
@@ -284,8 +287,19 @@ function targetOf(op: Op, input: unknown): { target?: string } {
|
|
|
284
287
|
* the running instance's people): while it holds or denies, the company's policy is not asked.
|
|
285
288
|
* Runs inside a step, so it must have no side effects of its own.
|
|
286
289
|
*/
|
|
287
|
-
export async function decide(
|
|
288
|
-
|
|
290
|
+
export async function decide(
|
|
291
|
+
run: Run,
|
|
292
|
+
op: Op,
|
|
293
|
+
input: unknown,
|
|
294
|
+
connection?: ConnectionSnapshot,
|
|
295
|
+
): Promise<RecordedDecision> {
|
|
296
|
+
const seen = {
|
|
297
|
+
id: run.id,
|
|
298
|
+
workflow: run.workflow,
|
|
299
|
+
approvals: structuredClone(run.approvals).filter(
|
|
300
|
+
(a) => a.requestedBy !== "policy" || a.connection === connection?.revision,
|
|
301
|
+
),
|
|
302
|
+
};
|
|
289
303
|
// Only a merge is the rule's: no other call reads the people's files.
|
|
290
304
|
const held =
|
|
291
305
|
op.id === CHANGE_MERGE ? runtimeHold(op.id, input, seen.approvals, readPeople(run.state.root).people) : undefined;
|
|
@@ -298,7 +312,7 @@ export async function decide(run: Run, op: Op, input: unknown): Promise<Recorded
|
|
|
298
312
|
{ idempotencyKey: `${run.id}:${run.seq}:before`, runId: run.id, opId: get, attempt: 1, secrets: secretsOf(run) },
|
|
299
313
|
),
|
|
300
314
|
);
|
|
301
|
-
const call = { ...callOf(op, input, run.actor, seen), ...before };
|
|
315
|
+
const call = { ...callOf(op, input, run.actor, seen), ...before, ...(connection && { connection }) };
|
|
302
316
|
return ask(policy, op.id, () => policy(call), run.state);
|
|
303
317
|
}
|
|
304
318
|
|
|
@@ -357,7 +371,7 @@ export function decideCall(
|
|
|
357
371
|
input: unknown,
|
|
358
372
|
actor: Principal,
|
|
359
373
|
run: PolicyCall["run"],
|
|
360
|
-
before: Pick<PolicyCall, "before"> = {},
|
|
374
|
+
before: Pick<PolicyCall, "before" | "connection"> = {},
|
|
361
375
|
): RecordedDecision {
|
|
362
376
|
const call = { ...callOf(op, input, actor, run), ...before };
|
|
363
377
|
return ask(policy, op.id, () => policy(call));
|
|
@@ -411,7 +425,7 @@ function ask(
|
|
|
411
425
|
return policy.version === undefined ? decision : { ...decision, policyVersion: policy.version };
|
|
412
426
|
}
|
|
413
427
|
|
|
414
|
-
async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
428
|
+
async function callOp(run: Run, id: string, input: unknown, place: Placed, ref?: ConnectionRef) {
|
|
415
429
|
// The worker's declaration, never the workflow's: its effect, schemas and retry setting.
|
|
416
430
|
const op = run.state.ops.get(id);
|
|
417
431
|
// A sandbox run calls its own fakes, never a vendor.
|
|
@@ -441,10 +455,36 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
441
455
|
// The decision is recorded as a step, so a replay reuses it instead of asking a policy
|
|
442
456
|
// that may have changed since. The policy must still be deterministic: a run that fails
|
|
443
457
|
// before the step is recorded asks again.
|
|
444
|
-
const
|
|
458
|
+
const connection = await DBOS.runStep(
|
|
459
|
+
async () => {
|
|
460
|
+
if (run.sandbox !== undefined) {
|
|
461
|
+
if (ref)
|
|
462
|
+
throw new SanomaError("invalid_input", "Named sandbox connections require explicit fake account state");
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
return run.state.connections?.select(op.vendor, ref, run.actor.id);
|
|
466
|
+
},
|
|
467
|
+
{ name: `connection:${op.id}` },
|
|
468
|
+
);
|
|
469
|
+
const decideStep = () =>
|
|
470
|
+
DBOS.runStep(
|
|
471
|
+
async () => {
|
|
472
|
+
await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
473
|
+
return decide(run, op, parsed, connection);
|
|
474
|
+
},
|
|
475
|
+
{ name: `policy:${op.id}` },
|
|
476
|
+
);
|
|
445
477
|
let decision = await decideStep();
|
|
446
478
|
const logged = recorded(op, parsed);
|
|
447
|
-
const call = {
|
|
479
|
+
const call = {
|
|
480
|
+
type: "op.called",
|
|
481
|
+
op: op.id,
|
|
482
|
+
...place,
|
|
483
|
+
effect: op.effect,
|
|
484
|
+
input: logged,
|
|
485
|
+
decision,
|
|
486
|
+
...(connection && { connection }),
|
|
487
|
+
} as const;
|
|
448
488
|
|
|
449
489
|
// Each hold is lifted in turn: once one is approved, the policy is asked again, with it.
|
|
450
490
|
for (;;) {
|
|
@@ -462,6 +502,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
462
502
|
seq,
|
|
463
503
|
input: logged,
|
|
464
504
|
reasons: decision.reasons,
|
|
505
|
+
connection: connection?.revision,
|
|
465
506
|
});
|
|
466
507
|
} catch (err) {
|
|
467
508
|
// A rejection or a withdrawal stops the call: it is recorded as failed, naming the approval.
|
|
@@ -486,6 +527,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
486
527
|
async () => {
|
|
487
528
|
const at = Date.now();
|
|
488
529
|
attempt = DBOS.stepStatus?.currentAttempt ?? 1;
|
|
530
|
+
await run.state.connections?.assertCurrent(connection, run.actor.id);
|
|
489
531
|
const secrets = secretsOf(run);
|
|
490
532
|
const context: CallContext = {
|
|
491
533
|
idempotencyKey: `${run.id}:${seq}`,
|
|
@@ -493,6 +535,8 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
493
535
|
opId: op.id,
|
|
494
536
|
attempt,
|
|
495
537
|
secrets,
|
|
538
|
+
principal: run.actor,
|
|
539
|
+
...(connection && { connection }),
|
|
496
540
|
// A call run is the person's direct call held for an approval: made as them, as it would have been.
|
|
497
541
|
by: run.workflow === CALL_WORKFLOW ? run.actor.id : `run:${run.id}`,
|
|
498
542
|
};
|
package/src/cedar.ts
CHANGED
|
@@ -403,7 +403,20 @@ export function cedarPolicy(
|
|
|
403
403
|
call.actor,
|
|
404
404
|
call.op.id,
|
|
405
405
|
call.target,
|
|
406
|
-
{
|
|
406
|
+
{
|
|
407
|
+
input: call.input,
|
|
408
|
+
...(call.before !== undefined && { before: call.before }),
|
|
409
|
+
...(call.connection && {
|
|
410
|
+
connection: {
|
|
411
|
+
id: call.connection.id,
|
|
412
|
+
vendor: call.connection.vendor,
|
|
413
|
+
method: call.connection.method,
|
|
414
|
+
account: call.connection.account,
|
|
415
|
+
revision: call.connection.revision,
|
|
416
|
+
scopes: call.connection.scopes,
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
},
|
|
407
420
|
call.run.approvals,
|
|
408
421
|
call.run.workflow === CALL_WORKFLOW,
|
|
409
422
|
call.entity,
|
package/src/change-steps.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
followBase,
|
|
16
16
|
repositoryOf,
|
|
17
17
|
isRules,
|
|
18
|
+
mayDeclarePeople,
|
|
18
19
|
sourceOfRun,
|
|
19
20
|
touchesOf,
|
|
20
21
|
} from "./change.ts";
|
|
@@ -23,7 +24,7 @@ import type { DriftReport } from "./drift.ts";
|
|
|
23
24
|
import { isRunControlError } from "./errors.ts";
|
|
24
25
|
import { warn } from "./log.ts";
|
|
25
26
|
import { inside } from "./paths.ts";
|
|
26
|
-
import {
|
|
27
|
+
import { peopleSources } from "./resources.ts";
|
|
27
28
|
import { currentRun, type Run } from "./run.ts";
|
|
28
29
|
import {
|
|
29
30
|
AGENTS,
|
|
@@ -48,8 +49,8 @@ const runNow = (): Run => {
|
|
|
48
49
|
return run;
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
/** True when the file at `path` is a people file (`
|
|
52
|
-
const fileDeclaresPeople = async (path: string) =>
|
|
52
|
+
/** True when the file at `path` is a people file (`mayDeclarePeople`), one `readPeople` would load; false when there is none. */
|
|
53
|
+
const fileDeclaresPeople = async (path: string) => mayDeclarePeople(await readFile(path, "utf8").catch(() => ""));
|
|
53
54
|
|
|
54
55
|
/** Which events win when several wait: one that ends the change over one that plans it again. */
|
|
55
56
|
const WEIGHT: Record<ChangeEvent["kind"], number> = { commits: 0, close: 1, closed: 2, merged: 3 };
|
|
@@ -188,12 +189,14 @@ export function changeSteps({ remotes, hosts }: ChangeStepsOptions): ChangeSteps
|
|
|
188
189
|
.filter((file) => file.startsWith("resources/"))
|
|
189
190
|
.flatMap((file) => [join(source.root, file), join(draft.root, file)].map(fileDeclaresPeople)),
|
|
190
191
|
);
|
|
192
|
+
const sources = peopleSources(source.root);
|
|
191
193
|
return {
|
|
192
194
|
touches,
|
|
193
195
|
strandedRuns: stranded.length,
|
|
194
196
|
stranded,
|
|
195
197
|
checksOk: check.ok,
|
|
196
|
-
|
|
198
|
+
// Or a file main's people are read from: a helper a people file imports.
|
|
199
|
+
rules: people.includes(true) || touches.some(isRules) || files.some((file) => sources.has(file)),
|
|
197
200
|
};
|
|
198
201
|
},
|
|
199
202
|
{ name: "change:plan" },
|
package/src/change.ts
CHANGED
|
@@ -320,6 +320,16 @@ export const ADMIN = "admin";
|
|
|
320
320
|
*/
|
|
321
321
|
export const isRules = (path: string) =>
|
|
322
322
|
path === "policies/" || path === "resources/people.ts" || /^apps\/[^/]+\/policies\/$/.test(path);
|
|
323
|
+
/**
|
|
324
|
+
* Whether a data file's text imports the runtime's constructors, `sanoma` (by any local name, or
|
|
325
|
+
* the whole entry, `* as`, from which it can reach them): a file that may declare a person, agent
|
|
326
|
+
* or group, which `readPeople` loads and a change to which is a change to the rules. Read from the
|
|
327
|
+
* text, so a draft's file is told without running it.
|
|
328
|
+
*/
|
|
329
|
+
export const mayDeclarePeople = (text: string): boolean =>
|
|
330
|
+
/(?:\{[^}]*\bsanoma\b[^}]*\}|\*(?:\s*as\s+[\w$]+)?)\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/.test(
|
|
331
|
+
text,
|
|
332
|
+
);
|
|
323
333
|
/** The runtime rule's id, as a hold's `reasons` name it. */
|
|
324
334
|
const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
|
|
325
335
|
|
package/src/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectionDriver, type ConnectionRef, type ConnectionSnapshot } from "./connection.ts";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { isDeepStrictEqual } from "node:util";
|
|
3
4
|
import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
|
|
@@ -16,7 +17,7 @@ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call
|
|
|
16
17
|
import { type Chats, chatStore } from "./chats.ts";
|
|
17
18
|
import { collectionsDriver } from "./collection.ts";
|
|
18
19
|
import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
19
|
-
import {
|
|
20
|
+
import { configuredSecrets } from "./credentials.ts";
|
|
20
21
|
import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
|
|
21
22
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
22
23
|
import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
|
|
@@ -52,7 +53,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
|
|
|
52
53
|
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
53
54
|
import { admit, readPeople } from "./resources.ts";
|
|
54
55
|
import type { RunArgs, StartHold } from "./run.ts";
|
|
55
|
-
import type {
|
|
56
|
+
import type { SecretAccess, SecretLocation } from "./secret-store.ts";
|
|
56
57
|
import {
|
|
57
58
|
BUILT_IN_WORKFLOWS,
|
|
58
59
|
CALL_WORKFLOW,
|
|
@@ -206,6 +207,7 @@ export interface ChangesFilter {
|
|
|
206
207
|
|
|
207
208
|
/** What `SanomaClient.call` takes besides the operation's id and its input. */
|
|
208
209
|
export interface CallOptions {
|
|
210
|
+
connection?: ConnectionRef;
|
|
209
211
|
/**
|
|
210
212
|
* Who calls, by id, admitted as `startedBy` is: the policy decides as them, a write's record
|
|
211
213
|
* names them, and a save's `updatedBy` is their id.
|
|
@@ -243,7 +245,7 @@ export class SanomaClient {
|
|
|
243
245
|
/** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
|
|
244
246
|
private readonly pool: LazyPool;
|
|
245
247
|
/** What a direct call's driver reads secrets from, as a worker's live driver does. */
|
|
246
|
-
private readonly storedSecrets:
|
|
248
|
+
private readonly storedSecrets: SecretAccess;
|
|
247
249
|
/** The operator agent's chats: their rows and their transcripts, in the config's database. */
|
|
248
250
|
readonly chats: Chats;
|
|
249
251
|
private readonly options: ClientOptions;
|
|
@@ -260,7 +262,7 @@ export class SanomaClient {
|
|
|
260
262
|
this.options = options;
|
|
261
263
|
this.audit = ownedLedger(config);
|
|
262
264
|
this.pool = lazyPool(config.databaseUrl, "client");
|
|
263
|
-
this.storedSecrets =
|
|
265
|
+
this.storedSecrets = configuredSecrets(config.secrets, () => this.db());
|
|
264
266
|
this.chats = chatStore(() => this.db());
|
|
265
267
|
}
|
|
266
268
|
|
|
@@ -272,7 +274,14 @@ export class SanomaClient {
|
|
|
272
274
|
static async connect(config: SanomaConfig, options: ClientOptions = {}): Promise<SanomaClient> {
|
|
273
275
|
const resolved = resolveConfig(config);
|
|
274
276
|
const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
|
|
275
|
-
|
|
277
|
+
const client = new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
|
|
278
|
+
try {
|
|
279
|
+
await client.storedSecrets.validate();
|
|
280
|
+
return client;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await client.close();
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
276
285
|
}
|
|
277
286
|
|
|
278
287
|
/**
|
|
@@ -414,14 +423,21 @@ export class SanomaClient {
|
|
|
414
423
|
const fn = drivers.get(op.id);
|
|
415
424
|
if (!fn) throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
|
|
416
425
|
const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
|
|
426
|
+
const connection = await this.config.connections.select(op.vendor, options.connection, actor.id);
|
|
427
|
+
const selected = connection && {
|
|
428
|
+
$connection: connection.id,
|
|
429
|
+
vendor: connection.vendor,
|
|
430
|
+
revision: connection.revision,
|
|
431
|
+
};
|
|
417
432
|
const runId = `direct:${actor.id}:${id}`;
|
|
418
433
|
const heldId = `call:${actor.id}:${id}`;
|
|
419
|
-
const held: RunArgs = { input: { op: op.id, input }, startedBy: actor };
|
|
434
|
+
const held: RunArgs = { input: { op: op.id, input, ...(selected && { connection: selected }) }, startedBy: actor };
|
|
420
435
|
const logged = recorded(op, parsed);
|
|
421
436
|
const write = op.effect !== "read";
|
|
422
437
|
// Only an id the caller gave can have been used: a write made, or held, with it settles it.
|
|
423
438
|
if (write && options.id !== undefined) {
|
|
424
|
-
const first =
|
|
439
|
+
const first =
|
|
440
|
+
(await this.recordedCall(runId, op.id, logged, connection)) ?? (await this.heldCall(op.id, heldId, held));
|
|
425
441
|
if (first) return first.output;
|
|
426
442
|
}
|
|
427
443
|
const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) =>
|
|
@@ -431,7 +447,7 @@ export class SanomaClient {
|
|
|
431
447
|
),
|
|
432
448
|
);
|
|
433
449
|
const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
|
|
434
|
-
const decision = decideCall(policy, op, parsed, actor, run, before);
|
|
450
|
+
const decision = decideCall(policy, op, parsed, actor, run, { ...before, ...(connection && { connection }) });
|
|
435
451
|
if (decision.kind === "deny") throw new PolicyDeniedError(op.id, decision.reason);
|
|
436
452
|
if (decision.kind === "approve") {
|
|
437
453
|
await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
|
|
@@ -445,7 +461,10 @@ export class SanomaClient {
|
|
|
445
461
|
attempt: 1,
|
|
446
462
|
secrets: this.storedSecrets,
|
|
447
463
|
by: actor.id,
|
|
464
|
+
principal: actor,
|
|
465
|
+
...(connection && { connection }),
|
|
448
466
|
};
|
|
467
|
+
await this.config.connections.assertCurrent(connection, actor.id);
|
|
449
468
|
if (!write) return outputOf(op, () => fn(parsed, context));
|
|
450
469
|
return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
|
|
451
470
|
}
|
|
@@ -469,13 +488,22 @@ export class SanomaClient {
|
|
|
469
488
|
const { op, parsed, logged, actor, decision, context } = call;
|
|
470
489
|
const { runId } = context;
|
|
471
490
|
const { dataStore, collections, ledger } = this.config;
|
|
472
|
-
const
|
|
491
|
+
const connectionMutation = op.id.startsWith("sanoma.connection.");
|
|
492
|
+
const transactionStore = connectionMutation
|
|
493
|
+
? this.config.connectionPool
|
|
494
|
+
: op.vendor === DATA_VENDOR
|
|
495
|
+
? dataStore
|
|
496
|
+
: undefined;
|
|
497
|
+
const conn = !ledger && transactionStore ? await (await transactionStore.db()).connect() : undefined;
|
|
473
498
|
let broken: Error | undefined;
|
|
474
499
|
try {
|
|
475
500
|
await conn?.query("BEGIN");
|
|
476
|
-
//
|
|
501
|
+
// The metadata or collection driver shares the ledger transaction; neither has an env guard.
|
|
477
502
|
const fn = conn
|
|
478
|
-
?
|
|
503
|
+
? (connectionMutation
|
|
504
|
+
? connectionDriver(this.config.connections.onDatabase(conn))
|
|
505
|
+
: collectionsDriver([collections.get(op.resource)!], { db: async () => conn })
|
|
506
|
+
).ops[`${op.resource}.${op.name}`]!
|
|
479
507
|
: call.fn;
|
|
480
508
|
const at = Date.now();
|
|
481
509
|
const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
|
|
@@ -492,6 +520,7 @@ export class SanomaClient {
|
|
|
492
520
|
effect: op.effect,
|
|
493
521
|
input: logged,
|
|
494
522
|
decision,
|
|
523
|
+
...(context.connection && { connection: context.connection }),
|
|
495
524
|
...outcome,
|
|
496
525
|
durationMs: Date.now() - at,
|
|
497
526
|
});
|
|
@@ -513,7 +542,7 @@ export class SanomaClient {
|
|
|
513
542
|
return output;
|
|
514
543
|
}
|
|
515
544
|
await conn.query("ROLLBACK");
|
|
516
|
-
return (await this.recordedCall(runId, op.id, logged))!.output;
|
|
545
|
+
return (await this.recordedCall(runId, op.id, logged, context.connection))!.output;
|
|
517
546
|
} catch (err) {
|
|
518
547
|
await conn?.query("ROLLBACK").catch((e: Error) => (broken = e));
|
|
519
548
|
throw err;
|
|
@@ -526,10 +555,19 @@ export class SanomaClient {
|
|
|
526
555
|
* What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
|
|
527
556
|
* when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
|
|
528
557
|
*/
|
|
529
|
-
private async recordedCall(
|
|
558
|
+
private async recordedCall(
|
|
559
|
+
runId: string,
|
|
560
|
+
opId: string,
|
|
561
|
+
input: unknown,
|
|
562
|
+
connection?: ConnectionSnapshot,
|
|
563
|
+
): Promise<{ output: unknown } | undefined> {
|
|
530
564
|
const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
|
|
531
565
|
if (!first) return undefined;
|
|
532
|
-
const differs = [
|
|
566
|
+
const differs = [
|
|
567
|
+
...(first.op === opId ? [] : ["op"]),
|
|
568
|
+
...(sameJson(first.input, input) ? [] : ["input"]),
|
|
569
|
+
...(sameJson(first.connection ?? null, connection ?? null) ? [] : ["connection"]),
|
|
570
|
+
];
|
|
533
571
|
if (differs.length) {
|
|
534
572
|
throw new SanomaError(
|
|
535
573
|
"invalid_input",
|
|
@@ -1061,7 +1099,8 @@ export class SanomaClient {
|
|
|
1061
1099
|
issues: [{ path: ["value"], message: problem, code: "custom" }],
|
|
1062
1100
|
});
|
|
1063
1101
|
}
|
|
1064
|
-
|
|
1102
|
+
const current = await this.storedSecrets.read(name);
|
|
1103
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
1065
1104
|
}
|
|
1066
1105
|
|
|
1067
1106
|
/**
|
|
@@ -1072,7 +1111,8 @@ export class SanomaClient {
|
|
|
1072
1111
|
async clearCredential(name: string, options: { by: string }): Promise<void> {
|
|
1073
1112
|
await this.admittedBy(options);
|
|
1074
1113
|
this.declared(name, () => undefined);
|
|
1075
|
-
|
|
1114
|
+
const current = await this.storedSecrets.read(name);
|
|
1115
|
+
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by: options.by });
|
|
1076
1116
|
}
|
|
1077
1117
|
|
|
1078
1118
|
/**
|
|
@@ -1091,7 +1131,21 @@ export class SanomaClient {
|
|
|
1091
1131
|
const path = VARIABLE.test(name) ? "value" : "name";
|
|
1092
1132
|
throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
|
|
1093
1133
|
}
|
|
1094
|
-
|
|
1134
|
+
const current = await this.storedSecrets.read(name);
|
|
1135
|
+
await this.storedSecrets.write(name, value, { expectedVersion: current?.version ?? null, by: setBy });
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Removes a logical secret from the selected store; external bindings remain read-only. */
|
|
1139
|
+
async clearSecret(name: string, options: { by: string }): Promise<void> {
|
|
1140
|
+
const by = await this.admittedBy(options);
|
|
1141
|
+
if (!VARIABLE.test(name)) {
|
|
1142
|
+
const message = "That is not a variable name";
|
|
1143
|
+
throw new SanomaError("invalid_input", message, {
|
|
1144
|
+
issues: [{ path: ["name"], message, code: "custom" }],
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
const current = await this.storedSecrets.read(name);
|
|
1148
|
+
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
|
|
1095
1149
|
}
|
|
1096
1150
|
|
|
1097
1151
|
/**
|
|
@@ -1100,40 +1154,51 @@ export class SanomaClient {
|
|
|
1100
1154
|
* is left out. For server code: a page is told whether one is set, never its value.
|
|
1101
1155
|
*/
|
|
1102
1156
|
async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
|
|
1103
|
-
const rows = await storedCredentials(await this.db(), names);
|
|
1104
1157
|
const found = new Map<string, StoredSecret>();
|
|
1105
1158
|
for (const name of names) {
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
1159
|
+
const row = await this.storedSecrets.read(name);
|
|
1160
|
+
const location = this.storedSecrets.location(name);
|
|
1161
|
+
if (row)
|
|
1162
|
+
found.set(name, {
|
|
1163
|
+
value: row.value,
|
|
1164
|
+
source: location.store === "environment" ? "environment" : "stored",
|
|
1165
|
+
version: row.version,
|
|
1166
|
+
location,
|
|
1167
|
+
...(row.updatedBy && { setBy: row.updatedBy }),
|
|
1168
|
+
...(row.updatedAt && { setAt: row.updatedAt }),
|
|
1169
|
+
});
|
|
1110
1170
|
}
|
|
1111
1171
|
return found;
|
|
1112
1172
|
}
|
|
1113
1173
|
|
|
1114
|
-
/**
|
|
1115
|
-
* Each vendor's declared variables and their statuses now: from this process's environment,
|
|
1116
|
-
* which wins, else from the stored credentials, with `source` saying which (a value equal to
|
|
1117
|
-
* the stored one, as a worker in this process loads it, is `stored`), and who set a stored one
|
|
1118
|
-
* and when. Never a value.
|
|
1119
|
-
*/
|
|
1174
|
+
/** Non-secret statuses for the selected store and declared bindings. */
|
|
1120
1175
|
async credentials(): Promise<Map<string, CredentialStatus[]>> {
|
|
1121
1176
|
const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1177
|
+
const rows = await this.secrets(names);
|
|
1178
|
+
const statuses = credentialsOf(this.drivers, (name) => rows.get(name)?.value);
|
|
1179
|
+
return new Map(
|
|
1180
|
+
[...statuses].map(([vendor, list]) => [
|
|
1181
|
+
vendor,
|
|
1182
|
+
list.map((c) => {
|
|
1183
|
+
const row = rows.get(c.name);
|
|
1184
|
+
return {
|
|
1185
|
+
...c,
|
|
1186
|
+
location: this.storedSecrets.location(c.name),
|
|
1187
|
+
...(row && { source: row.source, setBy: row.setBy, setAt: row.setAt }),
|
|
1188
|
+
};
|
|
1189
|
+
}),
|
|
1190
|
+
]),
|
|
1191
|
+
);
|
|
1133
1192
|
}
|
|
1134
1193
|
|
|
1135
1194
|
async close() {
|
|
1136
|
-
await Promise.all([
|
|
1195
|
+
await Promise.all([
|
|
1196
|
+
this.dbos.destroy(),
|
|
1197
|
+
this.pool.close(),
|
|
1198
|
+
this.audit.close(),
|
|
1199
|
+
this.config.dataStore?.close(),
|
|
1200
|
+
this.config.connectionPool.close(),
|
|
1201
|
+
]);
|
|
1137
1202
|
}
|
|
1138
1203
|
|
|
1139
1204
|
/**
|
|
@@ -1285,6 +1350,8 @@ const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a
|
|
|
1285
1350
|
|
|
1286
1351
|
/** A secret's value as `SanomaClient.secrets` reads it, and where from. */
|
|
1287
1352
|
export interface StoredSecret {
|
|
1353
|
+
version?: string;
|
|
1354
|
+
location?: SecretLocation;
|
|
1288
1355
|
value: string;
|
|
1289
1356
|
source: "environment" | "stored";
|
|
1290
1357
|
/** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
|
|
@@ -1293,7 +1360,7 @@ export interface StoredSecret {
|
|
|
1293
1360
|
}
|
|
1294
1361
|
|
|
1295
1362
|
/** What an environment variable's name looks like. */
|
|
1296
|
-
const VARIABLE = /^[A-
|
|
1363
|
+
const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
|
|
1297
1364
|
|
|
1298
1365
|
const alreadyDecided = (runId: string, a: ApprovalState) =>
|
|
1299
1366
|
new SanomaError(
|
package/src/collection.ts
CHANGED
|
@@ -99,10 +99,18 @@ export function collection<
|
|
|
99
99
|
const F extends Record<string, Field>,
|
|
100
100
|
const K extends RequiredTextKeys<F>,
|
|
101
101
|
>(declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
102
|
+
// Frame 2 of `callerFile` is this function's caller: the data file.
|
|
103
|
+
return collectionIn(appOfFile(callerFile()), declaration);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `collection()` in the app `app` names (none for the root's): what the loader's `collection` calls, knowing the file itself. */
|
|
107
|
+
export function collectionIn<
|
|
108
|
+
const N extends string,
|
|
109
|
+
const F extends Record<string, Field>,
|
|
110
|
+
const K extends RequiredTextKeys<F>,
|
|
111
|
+
>(app: string | undefined, declaration: { name: N; key: K; fields: F; title?: string }): Collection<N, F, K> {
|
|
102
112
|
checkCollection(declaration);
|
|
103
113
|
const { key, fields, title } = declaration;
|
|
104
|
-
// Frame 2 of `callerFile` is this function's caller: the data file.
|
|
105
|
-
const app = appOfFile(callerFile());
|
|
106
114
|
const name = qualified(app, declaration.name);
|
|
107
115
|
const record = z.strictObject(
|
|
108
116
|
Object.fromEntries(
|