@sanoma-ai/workflows 0.11.5 → 0.11.9

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.
Files changed (55) hide show
  1. package/README.md +16 -7
  2. package/dist/change.d.ts +2 -4
  3. package/dist/change.js +2 -3
  4. package/dist/change.js.map +1 -1
  5. package/dist/client.d.ts +6 -0
  6. package/dist/client.js +5 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.js +8 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/describe.js +1 -0
  11. package/dist/describe.js.map +1 -1
  12. package/dist/description.d.ts +12 -0
  13. package/dist/description.js +3 -0
  14. package/dist/description.js.map +1 -1
  15. package/dist/direct.d.ts +1 -1
  16. package/dist/direct.js +2 -1
  17. package/dist/direct.js.map +1 -1
  18. package/dist/host-derive.d.ts +3 -0
  19. package/dist/host-derive.js +9 -0
  20. package/dist/host-derive.js.map +1 -1
  21. package/dist/host.d.ts +2 -0
  22. package/dist/host.js +2 -1
  23. package/dist/host.js.map +1 -1
  24. package/dist/ledger.d.ts +6 -0
  25. package/dist/ledger.js +8 -0
  26. package/dist/ledger.js.map +1 -1
  27. package/dist/run-status.d.ts +10 -0
  28. package/dist/run-status.js +23 -0
  29. package/dist/run-status.js.map +1 -0
  30. package/dist/runtime-ops.d.ts +29 -2
  31. package/dist/runtime-ops.js +35 -8
  32. package/dist/runtime-ops.js.map +1 -1
  33. package/dist/secret-store.d.ts +4 -0
  34. package/dist/secret-store.js +8 -0
  35. package/dist/secret-store.js.map +1 -1
  36. package/dist/shared.d.ts +2 -0
  37. package/dist/shared.js +2 -0
  38. package/dist/shared.js.map +1 -1
  39. package/dist/source.d.ts +2 -0
  40. package/dist/source.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/change.ts +10 -3
  43. package/src/client.ts +14 -1
  44. package/src/config.ts +13 -1
  45. package/src/describe.ts +1 -0
  46. package/src/description.ts +3 -0
  47. package/src/direct.ts +2 -1
  48. package/src/host-derive.ts +11 -0
  49. package/src/host.ts +2 -1
  50. package/src/ledger.ts +12 -0
  51. package/src/run-status.ts +38 -0
  52. package/src/runtime-ops.ts +43 -7
  53. package/src/secret-store.ts +16 -3
  54. package/src/shared.ts +3 -0
  55. package/src/source.ts +2 -0
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { configuredSecrets } from "./credentials.ts";
3
+ import { inspectRun } from "./run-status.ts";
3
4
  import {
4
5
  configuredIdentityProvisioning,
5
6
  type IdentityProvisioning,
@@ -378,7 +379,11 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
378
379
  changeDriver(remotes),
379
380
  connectionDriver(connections),
380
381
  appsListDriver,
381
- runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities: identityProvisioning.store }),
382
+ runtimeDriver({
383
+ tokens: tokenStore(() => connectionPool.db()),
384
+ identities: identityProvisioning.store,
385
+ inspect: inspectRun(() => connectionPool.db(), appName),
386
+ }),
382
387
  ...(hostTyped ? [sourceDriver] : []),
383
388
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
384
389
  ],
@@ -412,6 +417,11 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
412
417
  drivers: driversByVendor,
413
418
  builtInStore: !config.secrets?.store,
414
419
  signingKeyBound: config.secrets?.bindings?.SANOMA_SIGNING_KEY !== undefined,
420
+ hostEnvironment: Object.fromEntries(
421
+ Object.entries(config.secrets?.bindings ?? {}).flatMap(([name, binding]) =>
422
+ binding.host && binding.env !== undefined ? [[binding.env, name]] : [],
423
+ ),
424
+ ),
415
425
  ...(isOp(config.login?.sender) && { sender: config.login.sender.vendor }),
416
426
  });
417
427
  if (types.size) {
@@ -739,6 +749,7 @@ async function readDeclared(
739
749
  drivers: ReadonlyMap<string, Driver>;
740
750
  builtInStore: boolean;
741
751
  signingKeyBound: boolean;
752
+ hostEnvironment: Readonly<Record<string, string>>;
742
753
  sender?: string;
743
754
  },
744
755
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
@@ -760,6 +771,7 @@ async function readDeclared(
760
771
  ...(run && { secrets: run.state.secrets }),
761
772
  builtInStore: options.builtInStore,
762
773
  signingKeyBound: options.signingKeyBound,
774
+ hostEnvironment: options.hostEnvironment,
763
775
  ...(options.sender !== undefined && { sender: options.sender }),
764
776
  mint: !!options.mint && !!run && !run.sandbox,
765
777
  });
package/src/describe.ts CHANGED
@@ -189,6 +189,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
189
189
  .map((name) => `${id}.${name}`)
190
190
  .toSorted(),
191
191
  ...(capabilities.length ? { capabilities } : {}),
192
+ ...(spec.host && { host: { lifetime: spec.host.capabilities.lifetime } }),
192
193
  };
193
194
  })
194
195
  .toSorted((a, b) => a.id.localeCompare(b.id));
@@ -2,6 +2,7 @@
2
2
  // types inferred from them. zod only, no Node: a page can parse a description it is sent, and the
3
3
  // app extends `ConfigDescriptionSchema` with what it adds (`AppConfigSchema`).
4
4
  import { z } from "zod";
5
+ import { LIFETIMES } from "./host.ts";
5
6
  import type { Outline } from "./outline.ts";
6
7
  import type { ResourceFields } from "./resource.ts";
7
8
  import { EFFECTS, FIELD_TYPES } from "./shared.ts";
@@ -322,6 +323,8 @@ export const ResourceTypeEntrySchema = z.strictObject({
322
323
  ops: z.array(z.string()),
323
324
  /** The contracts it meets (`CAPABILITIES`), in that order; absent when it meets none. */
324
325
  capabilities: z.array(z.enum(CAPABILITIES)).optional(),
326
+ /** For a host (`HostSpec`), how it keeps the runtime up (`HostLifetime`); absent for any other type. */
327
+ host: z.strictObject({ lifetime: z.enum(LIFETIMES) }).optional(),
325
328
  });
326
329
  export type ResourceTypeEntry = z.infer<typeof ResourceTypeEntrySchema>;
327
330
 
package/src/direct.ts CHANGED
@@ -5,7 +5,7 @@ import { APPS_LIST, DATA_VENDOR } from "./shared.ts";
5
5
  /**
6
6
  * Whether an operation may be called directly, outside a run (`SanomaClient.call`), as the
7
7
  * person: a collection's (`data.<name>.<op>`), the runtime's listing of apps (`sanoma.apps.list`,
8
- * which a page calls), or a `read` that is neither the runtime's own (the vendor `sanoma`) nor a
8
+ * which a page calls), run inspection, connection management, or a `read` that is neither the runtime's own (the vendor `sanoma`) nor a
9
9
  * resource type's (`isResourceOp`), which only drift and apply call.
10
10
  * Anything else (an effect at a vendor) is a workflow's. The one rule: the built-in `call`
11
11
  * workflow, the SDK and the app read it, from here or from `OpEntry.direct`, and never derive it.
@@ -16,6 +16,7 @@ export const isDirect = (
16
16
  ): boolean =>
17
17
  op.vendor === DATA_VENDOR ||
18
18
  op.id === APPS_LIST ||
19
+ op.id === "sanoma.run.inspect" ||
19
20
  op.id === "sanoma.connection.disconnect" ||
20
21
  op.id === "sanoma.connection.bind" ||
21
22
  op.id === "sanoma.connection.create" ||
@@ -78,6 +78,8 @@ export interface HostFacts {
78
78
  unbound?: ReadonlyMap<string, readonly string[]>;
79
79
  /** True when the config binds `SANOMA_SIGNING_KEY`: the host is given that key, else its own (`HOST_SIGNING_KEY`). */
80
80
  signingKeyBound?: boolean;
81
+ /** Explicit environment delivery: destination variable to logical secret name, never values. */
82
+ hostEnvironment?: Readonly<Record<string, string>>;
81
83
  /**
82
84
  * While the config has no deploy-key type, the GitHub repositories a host may clone (`origin`'s,
83
85
  * a declared `source`'s) that GitHub shows to no one: private (or not there). One GitHub does
@@ -352,10 +354,17 @@ export function deriveHosts(
352
354
  ]);
353
355
  const carried = [...vendors].flatMap((vendor) => facts.carried.get(vendor) ?? []);
354
356
  for (const name of [...carried, ...facts.githubApp]) secrets[name] = secret(name);
357
+ for (const [environment, name] of Object.entries(facts.hostEnvironment ?? {})) {
358
+ if (Object.hasOwn(secrets, environment) && secrets[environment]!.$secret !== name)
359
+ problem(`${environment} has a derived host binding and cannot be replaced by ${name}`);
360
+ else if (!Object.hasOwn(secrets, environment))
361
+ Object.defineProperty(secrets, environment, { value: secret(name), enumerable: true });
362
+ }
355
363
  // One the driver reads here from the environment alone would be left off the host, and a
356
364
  // Worker updated without it would lose it.
357
365
  for (const vendor of vendors) {
358
366
  for (const name of facts.unbound?.get(vendor) ?? []) {
367
+ if (Object.hasOwn(facts.hostEnvironment ?? {}, name)) continue;
359
368
  problem(
360
369
  `${name} is read from the environment here, and has no binding, so the host would not be given it: bind it in sanoma.config.ts, \`secrets: { bindings: { ${name}: { env: "${name}" } } }\`, or store it on the ${vendor} connector page`,
361
370
  );
@@ -428,6 +437,7 @@ export async function hostFacts(options: {
428
437
  builtInStore: boolean;
429
438
  /** True when the config binds `SANOMA_SIGNING_KEY` (`{ env }` or `{ bootstrap }`). */
430
439
  signingKeyBound?: boolean;
440
+ hostEnvironment?: Readonly<Record<string, string>>;
431
441
  sender?: string;
432
442
  mint: boolean;
433
443
  }): Promise<HostFacts | undefined> {
@@ -484,6 +494,7 @@ export async function hostFacts(options: {
484
494
  carried,
485
495
  ...(unbound.size > 0 && { unbound }),
486
496
  ...(options.signingKeyBound && { signingKeyBound: true }),
497
+ ...(options.hostEnvironment && { hostEnvironment: options.hostEnvironment }),
487
498
  ...(options.sender !== undefined && { sender: options.sender }),
488
499
  githubApp,
489
500
  ...(sourceKey !== undefined && { sourceKey }),
package/src/host.ts CHANGED
@@ -130,7 +130,8 @@ export const UNNAMED = "(the repository's name)";
130
130
  /** The port the runtime image's app listens on (its `PORT`): a host that derives runs it there. */
131
131
  export const RUNTIME_PORT = 3000;
132
132
 
133
- const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-when-quiet"];
133
+ /** Every `HostLifetime`, for a schema of one. */
134
+ export const LIFETIMES = ["long-lived", "stops-when-quiet"] as const satisfies readonly HostLifetime[];
134
135
 
135
136
  /** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
136
137
  export function hasPath(schema: z.ZodType, path: string): boolean {
package/src/ledger.ts CHANGED
@@ -190,6 +190,18 @@ export async function lastSignIns(
190
190
  return Object.fromEntries(last.flat());
191
191
  }
192
192
 
193
+ /** Who minted the latest invite for `person`, and until when (ms since the epoch): from their `invite:<person>` records; undefined when none was. */
194
+ export async function lastInvite(
195
+ store: LedgerStore,
196
+ person: string,
197
+ ): Promise<{ by: string; at: number; expiresAt: number } | undefined> {
198
+ const records = await store.read(`invite:${person}`);
199
+ const latest = records.reduce<LedgerRecord | undefined>((a, r) => (a && a.at > r.at ? a : r), undefined);
200
+ return latest?.type === "invite.minted"
201
+ ? { by: latest.by, at: latest.at, expiresAt: Date.parse(latest.expiresAt) }
202
+ : undefined;
203
+ }
204
+
193
205
  /**
194
206
  * The `ctx.all` member a record was written in. `id` is `all:<seq>`, the run's next `seq` when
195
207
  * the `ctx.all` began, so a replay names it the same; `index` is the member's position, `size`
@@ -0,0 +1,38 @@
1
+ import type { WorkflowStatusString } from "@dbos-inc/dbos-sdk";
2
+ import type { Db } from "./db.ts";
3
+
4
+ /** Metadata only: never read a run's inputs, outputs, error or authentication data. */
5
+ export type RunObservation = {
6
+ runId: string;
7
+ status: "missing" | "active" | "finished" | "failed" | "cancelled" | "interrupted";
8
+ workflow?: string;
9
+ };
10
+ export type InspectRun = (runId: string) => Promise<RunObservation>;
11
+
12
+ const statuses: Record<WorkflowStatusString, RunObservation["status"]> = {
13
+ ENQUEUED: "active",
14
+ DELAYED: "active",
15
+ PENDING: "active",
16
+ SUCCESS: "finished",
17
+ ERROR: "failed",
18
+ MAX_RECOVERY_ATTEMPTS_EXCEEDED: "interrupted",
19
+ CANCELLED: "cancelled",
20
+ };
21
+
22
+ /** A snapshot of the current application's run; unknown DBOS states remain active. */
23
+ export const inspectRun =
24
+ (database: () => Promise<Db>, appName: string): InspectRun =>
25
+ async (runId) => {
26
+ const db = await database();
27
+ const { rows } = await db.query<{ name: string; status: string }>(
28
+ "SELECT name,status FROM dbos.workflow_status WHERE workflow_uuid=$1 AND application_name=$2",
29
+ [runId, appName],
30
+ );
31
+ const row = rows[0];
32
+ if (!row) return { runId, status: "missing" };
33
+ return {
34
+ runId,
35
+ workflow: row.name,
36
+ status: Object.hasOwn(statuses, row.status) ? statuses[row.status as WorkflowStatusString] : "active",
37
+ };
38
+ };
@@ -7,6 +7,7 @@ import { sealInvite } from "./seal.ts";
7
7
  import { errorMessage } from "./shared.ts";
8
8
  import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
9
9
  import { memoryTokens, type Tokens } from "./tokens.ts";
10
+ import type { InspectRun } from "./run-status.ts";
10
11
  import {
11
12
  identityProvisioningCode,
12
13
  memoryIdentityProvisioning,
@@ -25,11 +26,17 @@ import {
25
26
  // registers them in every config with their driver (`runtimeDriver`), so a policy may name them;
26
27
  // a sandbox run calls `sandboxRuntimeDriver`, over its own secrets and tokens, posting nothing.
27
28
 
28
- /** A secret's name, as the store takes it (upper case), other than the runtime's own (`SANOMA_…`: its keys and credentials). */
29
- const SecretName = z
30
- .string()
31
- .regex(SECRET_NAME, "a secret's name: upper case, A-Z, 0-9 and _")
32
- .refine((name) => !name.startsWith("SANOMA_"), "a SANOMA_ name is the runtime's own, not a workflow's to use");
29
+ const StoredSecretName = z.string().regex(SECRET_NAME, "a secret's name: upper case, A-Z, 0-9 and _");
30
+ /** Generation destinations exclude the runtime's keys and credentials. */
31
+ const SecretName = StoredSecretName.refine(
32
+ (name) => !name.startsWith("SANOMA_"),
33
+ "a SANOMA_ name is the runtime's own, not a workflow's to use",
34
+ );
35
+ /** Database exports are stored values a lifecycle workflow can retire; bootstrap keys remain protected. */
36
+ const DeletableSecretName = StoredSecretName.refine(
37
+ (name) => !name.startsWith("SANOMA_") || /^SANOMA_DATABASE_URL_[A-Z0-9_]+$/.test(name),
38
+ "a SANOMA_ name may be deleted only when it is an exported database URL",
39
+ );
33
40
 
34
41
  /** How long an instance gets to answer `POST /auth/invite`. */
35
42
  const INVITE_TIMEOUT_MS = 10_000;
@@ -59,6 +66,21 @@ async function identityWrite(write: () => Promise<void>): Promise<void> {
59
66
  * `uses: [sanoma.secret.generate]` and `ctx.sanoma.secret.generate({ name, kind })`.
60
67
  */
61
68
  export const sanoma = defineConnector("sanoma", {
69
+ run: {
70
+ inspect: {
71
+ effect: "read",
72
+ idempotent: true,
73
+ description:
74
+ "Inspect a run's status in this application without its input, output or error. A snapshot, not a lock or proof that cancelled vendor calls have stopped",
75
+ input: z.object({ runId: z.string().min(1).max(512) }),
76
+ output: z.object({
77
+ runId: z.string(),
78
+ status: z.enum(["missing", "active", "finished", "failed", "cancelled", "interrupted"]),
79
+ workflow: z.string().optional(),
80
+ }),
81
+ target: ({ runId }: { runId: string }) => runId,
82
+ },
83
+ },
62
84
  secretStore: {
63
85
  prepare: {
64
86
  effect: "access",
@@ -127,8 +149,9 @@ export const sanoma = defineConnector("sanoma", {
127
149
  delete: {
128
150
  effect: "write",
129
151
  idempotent: true,
130
- description: "Delete a stored secret; one not stored is nothing to do",
131
- input: z.object({ name: SecretName }),
152
+ description:
153
+ "Delete a stored secret or exported database URL; one not stored is nothing to do. Runtime bootstrap secrets remain protected",
154
+ input: z.object({ name: DeletableSecretName }),
132
155
  output: z.object({}),
133
156
  target: ({ name }: { name: string }) => name,
134
157
  createsSecrets: ({ name }: { name: string }) => [name],
@@ -235,15 +258,27 @@ export async function postInvite(host: string, request: string): Promise<{ url:
235
258
  export function runtimeDriver({
236
259
  tokens,
237
260
  identities,
261
+ inspect,
238
262
  storage = prepareSecretStore,
239
263
  invite = postInvite,
240
264
  }: {
241
265
  tokens: Tokens;
242
266
  identities?: IdentityProvisioning;
267
+ inspect?: InspectRun;
243
268
  storage?: PrepareStorage;
244
269
  invite?: (host: string, request: string, call: CallContext) => Promise<{ url: string }>;
245
270
  }) {
246
271
  return defineDriver(sanoma, {
272
+ run: {
273
+ async inspect({ runId }) {
274
+ if (!inspect) throw refuse("run inspection is not configured");
275
+ try {
276
+ return await inspect(runId);
277
+ } catch {
278
+ throw new DriverError("Run inspection unavailable", { retryable: true });
279
+ }
280
+ },
281
+ },
247
282
  secretStore: {
248
283
  async prepare(input, call) {
249
284
  try {
@@ -344,6 +379,7 @@ export function runtimeDriver({
344
379
  export const sandboxRuntimeDriver = () =>
345
380
  runtimeDriver({
346
381
  tokens: memoryTokens(),
382
+ inspect: async (runId) => ({ runId, status: "missing" }),
347
383
  identities: memoryIdentityProvisioning(),
348
384
  storage: memoryStorageProvisioning(),
349
385
  invite: async (host, _request, call) => ({ url: new URL(`/auth/invite/sandbox-${tokenIdOf(call)}`, host).href }),
@@ -79,9 +79,16 @@ export const isSecretStoreFactory = (store: SecretStore | SecretStoreFactory): s
79
79
  "kind" in store && store.kind === "runtime";
80
80
 
81
81
  export type SecretBinding =
82
- | { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never }
83
- | { env: string; management?: "external"; path?: never; bootstrap?: never }
84
- | { bootstrap: DeploymentKeyName; management?: "external"; env?: never; path?: never };
82
+ | { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never; host?: never }
83
+ | {
84
+ env: string;
85
+ /** Deliver this binding to declared deriving hosts under its environment name. */
86
+ host?: boolean;
87
+ management?: "external";
88
+ path?: never;
89
+ bootstrap?: never;
90
+ }
91
+ | { bootstrap: DeploymentKeyName; management?: "external"; env?: never; path?: never; host?: never };
85
92
 
86
93
  export interface SecretConfiguration {
87
94
  /** Omitted: built-in encrypted Postgres, supplied by the runtime. */
@@ -161,8 +168,10 @@ export function checkSecretConfiguration(config: SecretConfiguration): void {
161
168
  (!config.bindings || typeof config.bindings !== "object" || Array.isArray(config.bindings))
162
169
  )
163
170
  invalid();
171
+ const hostEnvironment = new Set<string>();
164
172
  for (const [name, b] of Object.entries(config.bindings ?? {})) {
165
173
  if (!namePattern.test(name) || !b || typeof b !== "object") invalid();
174
+ if (b.host !== undefined && (typeof b.env !== "string" || typeof b.host !== "boolean")) invalid();
166
175
  if (DEPLOYMENT_KEYS.includes(name) && !("env" in b) && !("bootstrap" in b)) invalid();
167
176
  if ("bootstrap" in b) {
168
177
  if (
@@ -180,6 +189,10 @@ export function checkSecretConfiguration(config: SecretConfiguration): void {
180
189
  (b.management !== undefined && b.management !== "external")
181
190
  )
182
191
  invalid();
192
+ if (b.host) {
193
+ if (hostEnvironment.has(b.env!)) invalid();
194
+ hostEnvironment.add(b.env!);
195
+ }
183
196
  } else if (!nonempty(b.path) || !["sanoma", "external"].includes(b.management)) invalid();
184
197
  }
185
198
  }
package/src/shared.ts CHANGED
@@ -387,6 +387,9 @@ export function refusalOf(
387
387
  /** A declared resource as the people functions read it: only the runtime's people types count. */
388
388
  export type PeopleResource = Pick<DeclaredResource, "vendor" | "type" | "desired">;
389
389
 
390
+ /** The group the runtime holds a change to the rules for, and the one that hands out invite links. */
391
+ export const ADMIN = "admin";
392
+
390
393
  /** The group every agent is in, the runtime's own among them: no data file declares it. */
391
394
  export const AGENTS = "agents";
392
395
 
package/src/source.ts CHANGED
@@ -322,6 +322,8 @@ export interface RemoteHalf {
322
322
  webhook?(request: Request, context: RemoteContext): Promise<RemoteEvent[]>;
323
323
  /** The accounts the company's app on the host is installed on: where it acts with no person's token. */
324
324
  owners?(context: RemoteContext): Promise<{ login: string; kind: "user" | "organization" }[]>;
325
+ /** The permissions the company's app on the host holds, by name (`contents: "write"`), as the host lists them; undefined without the app's keys. */
326
+ permissions?(context: RemoteContext): Promise<Record<string, string> | undefined>;
325
327
  }
326
328
 
327
329
  /** The remote half among `remotes` (`ResolvedConfig.remotes`) whose host `url` is on, with its vendor and the repository. */