@sanoma-ai/workflows 0.7.0 → 0.8.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 +43 -12
- package/dist/apps-list.d.ts +90 -0
- package/dist/apps-list.js +109 -0
- package/dist/apps-list.js.map +1 -0
- package/dist/apps.d.ts +42 -0
- package/dist/apps.js +137 -0
- package/dist/apps.js.map +1 -0
- package/dist/call-workflow.js +5 -6
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.js +6 -3
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +1 -1
- package/dist/cedar.js +6 -4
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.d.ts +10 -0
- package/dist/change-steps.js +204 -0
- package/dist/change-steps.js.map +1 -0
- package/dist/change-workflow.d.ts +54 -0
- package/dist/change-workflow.js +161 -0
- package/dist/change-workflow.js.map +1 -0
- package/dist/change.d.ts +121 -24
- package/dist/change.js +196 -17
- package/dist/change.js.map +1 -1
- package/dist/claims.d.ts +58 -0
- package/dist/claims.js +27 -0
- package/dist/claims.js.map +1 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +268 -6
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +6 -1
- package/dist/collection.js +11 -3
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +56 -8
- package/dist/config.js +71 -29
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +37 -1
- package/dist/constructors.js +6 -2
- package/dist/constructors.js.map +1 -1
- package/dist/db.d.ts +21 -21
- package/dist/db.js +31 -54
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +18 -12
- package/dist/deadlines.js +18 -24
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +30 -14
- package/dist/define.js +9 -1
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +15 -4
- package/dist/demo.js +27 -12
- package/dist/demo.js.map +1 -1
- package/dist/describe.d.ts +16 -2
- package/dist/describe.js +39 -4
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +4 -3
- package/dist/direct.js +7 -4
- package/dist/direct.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +26 -0
- package/dist/fake.js +123 -0
- package/dist/fake.js.map +1 -1
- package/dist/host.d.ts +2 -3
- package/dist/host.js +1 -1
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/lint.js +20 -6
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +8 -1
- package/dist/op.js.map +1 -1
- package/dist/outline.js +13 -6
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +54 -2
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +5 -0
- package/dist/person.js +3 -1
- package/dist/person.js.map +1 -1
- package/dist/resources.d.ts +7 -2
- package/dist/resources.js +27 -10
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +7 -0
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +2 -2
- package/dist/scenario.js +10 -4
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +1 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +19 -5
- package/dist/sdk.js.map +1 -1
- package/dist/shared.d.ts +43 -2
- package/dist/shared.js +46 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +26 -5
- package/dist/sites.js +56 -20
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +179 -5
- package/dist/source.js +13 -0
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +5 -4
- package/dist/supervisor.js +2 -11
- package/dist/supervisor.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.js +21 -13
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.d.ts +128 -0
- package/dist/wakeup-driver.js +216 -0
- package/dist/wakeup-driver.js.map +1 -0
- package/dist/wakeup-service.d.ts +4 -4
- package/dist/wakeup-service.js +19 -25
- package/dist/wakeup-service.js.map +1 -1
- package/dist/wakeup-type.d.ts +43 -0
- package/dist/wakeup-type.js +80 -0
- package/dist/wakeup-type.js.map +1 -0
- package/dist/wakeup.d.ts +1 -8
- package/dist/wakeup.js +2 -11
- package/dist/wakeup.js.map +1 -1
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +50 -7
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -2
- package/package.json +3 -1
- package/src/apps-list.ts +119 -0
- package/src/apps.ts +162 -0
- package/src/call-workflow.ts +10 -2
- package/src/call.ts +6 -3
- package/src/cedar.ts +9 -7
- package/src/change-steps.ts +274 -0
- package/src/change-workflow.ts +198 -0
- package/src/change.ts +257 -27
- package/src/claims.ts +79 -0
- package/src/client.ts +320 -4
- package/src/collection.ts +22 -3
- package/src/config.ts +104 -35
- package/src/constructors.ts +8 -2
- package/src/db.ts +39 -66
- package/src/deadlines.ts +27 -30
- package/src/define.ts +31 -12
- package/src/demo.ts +55 -24
- package/src/describe.ts +67 -4
- package/src/direct.ts +9 -5
- package/src/errors.ts +3 -0
- package/src/fake.ts +137 -0
- package/src/host.ts +3 -4
- package/src/index.ts +22 -3
- package/src/lint.ts +21 -6
- package/src/op.ts +8 -1
- package/src/outline.ts +15 -6
- package/src/paths.ts +57 -2
- package/src/person.ts +4 -1
- package/src/resources.ts +31 -11
- package/src/run.ts +5 -0
- package/src/scenario.ts +14 -4
- package/src/scenarios.ts +1 -1
- package/src/sdk.ts +21 -7
- package/src/shared.ts +60 -5
- package/src/sites.ts +70 -24
- package/src/source.ts +168 -5
- package/src/supervisor.ts +7 -12
- package/src/version.ts +25 -16
- package/src/wakeup-driver.ts +270 -0
- package/src/wakeup-service.ts +21 -28
- package/src/wakeup-type.ts +90 -0
- package/src/wakeup.ts +6 -17
- package/src/worker.ts +48 -9
package/src/demo.ts
CHANGED
|
@@ -2,9 +2,9 @@ import type { SanomaClient } from "./client.ts";
|
|
|
2
2
|
import type { ResolvedConfig } from "./config.ts";
|
|
3
3
|
import { DATA_TABLE } from "./db.ts";
|
|
4
4
|
import { applyGivens } from "./sandbox.ts";
|
|
5
|
-
import { loadScenarios, type Moment } from "./scenario.ts";
|
|
5
|
+
import { type Given, loadScenarios, type Moment } from "./scenario.ts";
|
|
6
6
|
import { memorySecrets } from "./secret.ts";
|
|
7
|
-
import { errorMessage } from "./shared.ts";
|
|
7
|
+
import { DATA_VENDOR, errorMessage } from "./shared.ts";
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
10
|
* A demo: a company repo run on its connectors' fakes, with the world its scenarios describe.
|
|
@@ -27,20 +27,43 @@ export interface DemoState {
|
|
|
27
27
|
const states = new WeakMap<ResolvedConfig, DemoState>();
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* A half of a demo's seed (`reseed`'s `only`): what goes into its `database` (the runs cancelled,
|
|
31
|
+
* `sanoma_data` emptied, the worlds' collection records, the moments' runs), or into its `memory`
|
|
32
|
+
* (the instance's fakes reset, the worlds' vendor objects and calls). A copy of a seeded database
|
|
33
|
+
* holds the first half but not the second, which a process over it applies to its own fakes.
|
|
34
|
+
*/
|
|
35
|
+
export type SeedHalf = "database" | "memory";
|
|
36
|
+
|
|
37
|
+
/** Which half a world's `Given` goes to: a collection's operation the database, anything else memory; a fault both, each refusing it. */
|
|
38
|
+
const halfOf = (g: Given, resolved: ResolvedConfig): SeedHalf | undefined =>
|
|
39
|
+
"fault" in g ? undefined : "op" in g && resolved.ops.get(g.op)?.vendor === DATA_VENDOR ? "database" : "memory";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Seeds the demo instance afresh: cancels the app's unfinished runs (`cancelUnfinished`: the last
|
|
43
|
+
* seed's moments among them), empties the collections' table, resets the instance's fakes,
|
|
31
44
|
* applies every world (a scenario with no `When`) to the instance (`applyGivens`: its records
|
|
32
45
|
* saved through the collections' driver, its vendor objects put in the fakes), then starts each
|
|
33
46
|
* moment as a sandbox run by its person, left undriven, so its first approval waits for them.
|
|
34
|
-
* `resolved` is the resolution the worker and `client` run
|
|
35
|
-
* A world or a moment that fails is a problem in the state it
|
|
47
|
+
* `only` seeds one half (`SeedHalf`). `resolved` is the resolution the worker and `client` run
|
|
48
|
+
* on, made with `fakes: "instance"`. A world or a moment that fails is a problem in the state it
|
|
49
|
+
* returns, and the rest go on.
|
|
36
50
|
*/
|
|
37
|
-
export async function reseed(
|
|
51
|
+
export async function reseed(
|
|
52
|
+
client: SanomaClient,
|
|
53
|
+
resolved: ResolvedConfig,
|
|
54
|
+
{ only }: { only?: SeedHalf } = {},
|
|
55
|
+
): Promise<DemoState> {
|
|
38
56
|
const fakes = resolved.instanceFakes;
|
|
39
57
|
if (!fakes) throw new Error('reseed seeds an instance on fakes: resolve its config with `fakes: "instance"`');
|
|
58
|
+
const into = (half: SeedHalf) => only === undefined || only === half;
|
|
40
59
|
// Read first, so scenarios that cannot be read at all leave the instance as it was.
|
|
41
60
|
const { scenarios, errors } = await loadScenarios(resolved);
|
|
42
|
-
if (
|
|
43
|
-
|
|
61
|
+
if (into("database")) {
|
|
62
|
+
// The last seed's moments, and whatever a visitor started since, end here: nothing is kept.
|
|
63
|
+
await client.cancelUnfinished();
|
|
64
|
+
if (resolved.dataStore) await (await resolved.dataStore.db()).query(`TRUNCATE ${DATA_TABLE}`);
|
|
65
|
+
}
|
|
66
|
+
if (into("memory")) for (const fake of fakes.values()) fake.reset();
|
|
44
67
|
const state: DemoState = {
|
|
45
68
|
seededAt: new Date().toISOString(),
|
|
46
69
|
worlds: [],
|
|
@@ -49,28 +72,36 @@ export async function reseed(client: SanomaClient, resolved: ResolvedConfig): Pr
|
|
|
49
72
|
};
|
|
50
73
|
const secrets = memorySecrets();
|
|
51
74
|
for (const world of scenarios.filter((s) => s.workflow === undefined)) {
|
|
75
|
+
// Each Given keeps its place in the world, so its call's idempotency key is the same in either half.
|
|
76
|
+
const kept = [...world.given.entries()].filter(([, g]) => {
|
|
77
|
+
const half = halfOf(g, resolved);
|
|
78
|
+
return half === undefined || into(half);
|
|
79
|
+
});
|
|
52
80
|
try {
|
|
53
|
-
await applyGivens(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
await applyGivens(
|
|
82
|
+
{ name: world.name, given: kept.map(([, g]) => g) },
|
|
83
|
+
{
|
|
84
|
+
kind: "instance",
|
|
85
|
+
drivers: resolved.drivers,
|
|
86
|
+
fakes,
|
|
87
|
+
ops: resolved.ops,
|
|
88
|
+
call: (i, opId) => ({
|
|
89
|
+
idempotencyKey: `world:${world.name}:${kept[i]![0]}`,
|
|
90
|
+
runId: `world:${world.name}`,
|
|
91
|
+
opId,
|
|
92
|
+
attempt: 1,
|
|
93
|
+
secrets,
|
|
94
|
+
// Who its records are by: whoever it says is acting, else the runtime's own agent.
|
|
95
|
+
by: world.startedBy ?? "scenarios",
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
68
99
|
state.worlds.push(world.name);
|
|
69
100
|
} catch (err) {
|
|
70
101
|
state.problems.push(`world "${world.name}": ${errorMessage(err)}`);
|
|
71
102
|
}
|
|
72
103
|
}
|
|
73
|
-
for (const s of scenarios) {
|
|
104
|
+
for (const s of into("database") ? scenarios : []) {
|
|
74
105
|
if (!s.moment || s.workflow === undefined) continue;
|
|
75
106
|
try {
|
|
76
107
|
const runId = await client.start(s.workflow, s.input, { sandbox: s.name, startedBy: s.moment.person });
|
package/src/describe.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { z } from "zod";
|
|
3
|
+
import { type AppEntry, appsAt } from "./apps.ts";
|
|
3
4
|
import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
|
|
4
5
|
import { type Field, recordSchemaOf } from "./collection.ts";
|
|
5
6
|
import { connectorsOf, resolveConfig, rootOf, type SanomaConfig } from "./config.ts";
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
type DataFiles,
|
|
15
16
|
type DeclaredCollection,
|
|
16
17
|
type DeclaredResource,
|
|
18
|
+
inAppPeople,
|
|
17
19
|
readDataFiles,
|
|
18
20
|
type ResourceProblem,
|
|
19
21
|
} from "./resources.ts";
|
|
@@ -62,8 +64,19 @@ export {
|
|
|
62
64
|
type Bindings,
|
|
63
65
|
type Rebind,
|
|
64
66
|
} from "./lockfile.ts";
|
|
67
|
+
// The apps under `apps/`, read without running their `app.ts`.
|
|
68
|
+
export { crossAppImports, readApps, type AppEntry } from "./apps.ts";
|
|
65
69
|
// What the sites call, read from their source: a workflow's Used by.
|
|
66
|
-
export {
|
|
70
|
+
export {
|
|
71
|
+
readSites,
|
|
72
|
+
type SiteCall,
|
|
73
|
+
type SiteEntry,
|
|
74
|
+
type SitePage,
|
|
75
|
+
siteContentPages,
|
|
76
|
+
siteIcons,
|
|
77
|
+
siteLocales,
|
|
78
|
+
sitesUnder,
|
|
79
|
+
} from "./sites.ts";
|
|
67
80
|
// The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
|
|
68
81
|
export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
|
|
69
82
|
// The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
|
|
@@ -76,7 +89,10 @@ export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanS
|
|
|
76
89
|
|
|
77
90
|
/** What a workflow is, read from its definition: enough to draw a start form and show what it may call. */
|
|
78
91
|
export interface WorkflowEntry {
|
|
92
|
+
/** Its name: `<app>.<name>` for one an app's folder defines. */
|
|
79
93
|
name: string;
|
|
94
|
+
/** The app whose folder defines it (`apps/<app>/workflows/`); absent for one at the root. */
|
|
95
|
+
app?: string;
|
|
80
96
|
title?: string;
|
|
81
97
|
/** `manual`, or the schedule it starts on (`defineWorkflow`'s `trigger`). */
|
|
82
98
|
trigger: Trigger;
|
|
@@ -166,7 +182,10 @@ export interface ResourceTypeEntry {
|
|
|
166
182
|
|
|
167
183
|
/** A collection the config declares (`collection()`). */
|
|
168
184
|
export interface CollectionEntry {
|
|
185
|
+
/** Its name: `<app>.<name>` for one an app's `resources/data.ts` declares. */
|
|
169
186
|
name: string;
|
|
187
|
+
/** The app whose folder declares it (`apps/<app>/resources/data.ts`); absent for one at the root. */
|
|
188
|
+
app?: string;
|
|
170
189
|
title?: string;
|
|
171
190
|
/** The field each record is found by. */
|
|
172
191
|
key: string;
|
|
@@ -204,8 +223,14 @@ export interface ConfigDescription {
|
|
|
204
223
|
resources: DeclaredResource[];
|
|
205
224
|
/** Each binding in the lockfile (`LOCKFILE`) whose key no data file declares, as `readDataFiles` reports it. */
|
|
206
225
|
orphans: DataFiles["orphans"];
|
|
207
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* What is wrong in the data files (`readDataFiles`) and the apps (a folder of `apps/` that is no
|
|
228
|
+
* app, an `app.ts` that cannot be read, a collection or site at the root named as an app is),
|
|
229
|
+
* each at its file, relative to the root, line and column. A workflow's are on its entry.
|
|
230
|
+
*/
|
|
208
231
|
problems: ResourceProblem[];
|
|
232
|
+
/** The apps under `apps/` (`readApps`), by name. Each workflow, collection and site an app declares names it (`app`). */
|
|
233
|
+
apps: AppEntry[];
|
|
209
234
|
/**
|
|
210
235
|
* `defined` when the root has `policies/**\/*.cedar` (else the policy is `allowAll`), with their
|
|
211
236
|
* `version`, or while they have `problems` (`policyProblems`), none: then every call and start is refused.
|
|
@@ -258,6 +283,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
258
283
|
const collections: CollectionEntry[] = [...resolved.collections.values()]
|
|
259
284
|
.map((c) => ({
|
|
260
285
|
name: c.name,
|
|
286
|
+
...(c.app !== undefined && { app: c.app }),
|
|
261
287
|
...(c.title !== undefined && { title: c.title }),
|
|
262
288
|
key: c.key,
|
|
263
289
|
fields: c.fields,
|
|
@@ -294,6 +320,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
294
320
|
];
|
|
295
321
|
return {
|
|
296
322
|
name: wf.name,
|
|
323
|
+
...(wf.app !== undefined && { app: wf.app }),
|
|
297
324
|
title: wf.title,
|
|
298
325
|
trigger: wf.trigger,
|
|
299
326
|
...(builtin && { builtin: true as const }),
|
|
@@ -308,6 +335,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
308
335
|
});
|
|
309
336
|
workflows.sort((a, b) => a.name.localeCompare(b.name));
|
|
310
337
|
addApiNameProblems(workflows, new Set(ops.map((op) => op.vendor)));
|
|
338
|
+
const { apps, problems: appProblems } = appsAt(resolved.root);
|
|
311
339
|
const sites = readSites(
|
|
312
340
|
resolved.root,
|
|
313
341
|
{ workflows, collections, ops },
|
|
@@ -323,9 +351,16 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
323
351
|
vendors,
|
|
324
352
|
resourceTypes,
|
|
325
353
|
collections,
|
|
326
|
-
|
|
354
|
+
// An app's people are refused (`readPeople`), so the grid and the People page never show them.
|
|
355
|
+
resources: dataFiles.resources.filter((r) => !inAppPeople(r)),
|
|
327
356
|
orphans: dataFiles.orphans,
|
|
328
|
-
problems: [
|
|
357
|
+
problems: [
|
|
358
|
+
...dataFiles.problems,
|
|
359
|
+
...appProblems,
|
|
360
|
+
...appClashes(apps, collections, sites),
|
|
361
|
+
...domainProblems(resolved.root, dataFiles.resources, resources, sites, read),
|
|
362
|
+
],
|
|
363
|
+
apps,
|
|
329
364
|
policy: {
|
|
330
365
|
defined: resolved.policy !== allowAll,
|
|
331
366
|
...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
|
|
@@ -335,6 +370,34 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
335
370
|
};
|
|
336
371
|
}
|
|
337
372
|
|
|
373
|
+
/** That the root's `kind` `name`, in `file`, is named as an app is. */
|
|
374
|
+
const appClash = (kind: string, name: string, file: string): ResourceProblem => ({
|
|
375
|
+
file,
|
|
376
|
+
line: 1,
|
|
377
|
+
column: 1,
|
|
378
|
+
message: `the ${kind} ${name} is named as the app apps/${name} is: name it otherwise`,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A collection or site at the root named as an app is: `api.data.<app>` and `/sites/<app>/` are
|
|
383
|
+
* the app's, so the root's would take its place.
|
|
384
|
+
*/
|
|
385
|
+
function appClashes(
|
|
386
|
+
apps: readonly AppEntry[],
|
|
387
|
+
collections: readonly CollectionEntry[],
|
|
388
|
+
sites: readonly SiteEntry[],
|
|
389
|
+
): ResourceProblem[] {
|
|
390
|
+
const names = new Set(apps.map((app) => app.name));
|
|
391
|
+
return [
|
|
392
|
+
...collections
|
|
393
|
+
.filter((c) => c.app === undefined && names.has(c.name))
|
|
394
|
+
.map((c) => appClash("collection", c.name, c.declared?.file ?? "resources/data.ts")),
|
|
395
|
+
...sites
|
|
396
|
+
.filter((site) => site.app === undefined && names.has(site.name))
|
|
397
|
+
.map((site) => appClash("site", site.name, `sites/${site.name}`)),
|
|
398
|
+
];
|
|
399
|
+
}
|
|
400
|
+
|
|
338
401
|
/**
|
|
339
402
|
* Each site a host's `domains` (its type's `host.inputs.domains`) names that is not a public
|
|
340
403
|
* site, and each domain it gives more than one site, at the host's declaration: the app serves
|
package/src/direct.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import type { Op } from "./op.ts";
|
|
2
2
|
import { isResourceOp } from "./resource.ts";
|
|
3
|
-
import { DATA_VENDOR } from "./shared.ts";
|
|
3
|
+
import { APPS_LIST, DATA_VENDOR } from "./shared.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Whether an operation may be called directly, outside a run (`SanomaClient.call`), as the
|
|
7
|
-
* person: a collection's (`data.<name>.<op>`),
|
|
8
|
-
*
|
|
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
|
|
9
|
+
* resource type's (`isResourceOp`), which only drift and apply call.
|
|
9
10
|
* Anything else (an effect at a vendor) is a workflow's. The one rule: the built-in `call`
|
|
10
11
|
* workflow, the SDK and the app read it, from here or from `OpEntry.direct`, and never derive it.
|
|
11
12
|
*/
|
|
12
13
|
export const isDirect = (
|
|
13
|
-
op: Pick<Op, "vendor" | "resource" | "effect">,
|
|
14
|
+
op: Pick<Op, "id" | "vendor" | "resource" | "effect">,
|
|
14
15
|
types: ReadonlyMap<string, unknown>,
|
|
15
|
-
): boolean =>
|
|
16
|
+
): boolean =>
|
|
17
|
+
op.vendor === DATA_VENDOR ||
|
|
18
|
+
op.id === APPS_LIST ||
|
|
19
|
+
(op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));
|
package/src/errors.ts
CHANGED
|
@@ -51,6 +51,9 @@ export function errorCode(e: unknown): ErrorCode | undefined {
|
|
|
51
51
|
return code !== undefined && KNOWN.has(code) ? (code as ErrorCode) : undefined;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** A `DriverError`'s `vendorCode` (`no_token`, `bad_signature`), read as a property: undefined for anything else. */
|
|
55
|
+
export const vendorCodeOf = (e: unknown): string | undefined => own<string>(e, "vendorCode", "string");
|
|
56
|
+
|
|
54
57
|
/**
|
|
55
58
|
* True when the error says trying again would only repeat it (`retryable: false`), as a
|
|
56
59
|
* `DriverError` for a vendor's final answer or a ledger store for a corrupt file does. Read as
|
package/src/fake.ts
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
type Specs,
|
|
10
10
|
withSetup,
|
|
11
11
|
} from "./op.ts";
|
|
12
|
+
import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
|
|
13
|
+
import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
|
|
14
|
+
import { wakeupOps } from "./wakeup-type.ts";
|
|
12
15
|
|
|
13
16
|
/*
|
|
14
17
|
* Fake vendors for tests, at `@sanoma-ai/workflows/fake`. Kept off the main entry so the
|
|
@@ -166,3 +169,137 @@ export function defineFake<V extends string, S extends Specs, T extends Record<s
|
|
|
166
169
|
export function withFake<C extends Connector<any, any>>(connector: C, fake: Fake<any, any>): C {
|
|
167
170
|
return withSetup(connector, { driver: () => fake.driver, fake: () => fake });
|
|
168
171
|
}
|
|
172
|
+
|
|
173
|
+
// The built-in `sanoma.wakeup`'s fake: an in-memory wakeup service that calls the host's `/wake`.
|
|
174
|
+
|
|
175
|
+
/** An instance of the fake wakeup service, as it keeps one. */
|
|
176
|
+
interface FakeInstance extends Instance {
|
|
177
|
+
token: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface FakeWakeupState extends Record<string, unknown> {
|
|
181
|
+
seq: number;
|
|
182
|
+
instances: Record<string, FakeInstance>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How long the fake waits before it sends a wake again that the host did not answer. */
|
|
186
|
+
const RETRY_MS = 250;
|
|
187
|
+
/** setTimeout's longest delay: a longer one fires at once. */
|
|
188
|
+
const MAX_DELAY_MS = 2 ** 31 - 1;
|
|
189
|
+
|
|
190
|
+
/** The URL as the service keeps it (`instanceUrl`, its own rule), or the service's 400. */
|
|
191
|
+
function checkedUrl(value: string): string {
|
|
192
|
+
const checked = instanceUrl(value);
|
|
193
|
+
if ("error" in checked) throw fail(checked.error, 400);
|
|
194
|
+
return checked.url;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* An in-memory wakeup service for tests, as `sanoma wakeup` answers: instances in
|
|
199
|
+
* `state.instances`, ids made in order (UUID-shaped), each named by the token it was posted with
|
|
200
|
+
* (the call's `SANOMA_WAKEUP_TOKEN`), so posting it again moves it. `register` takes only the
|
|
201
|
+
* instance's own token and replaces the wake it had; it then sends `GET {host}/wake` at `at`,
|
|
202
|
+
* again every 250 ms until the host answers 2xx. `fire()` sends every armed wake now; `close()`
|
|
203
|
+
* (and `reset()`) disarms them.
|
|
204
|
+
*/
|
|
205
|
+
export function fakeSanomaWakeup(options: FakeOptions = {}) {
|
|
206
|
+
/** The wake each instance waits to send, by its id. */
|
|
207
|
+
const armed = new Map<string, { at: number; url: string; timeout?: NodeJS.Timeout; sending?: boolean }>();
|
|
208
|
+
const disarm = (id: string) => {
|
|
209
|
+
clearTimeout(armed.get(id)?.timeout);
|
|
210
|
+
armed.delete(id);
|
|
211
|
+
};
|
|
212
|
+
/** Sends `id`'s armed wake once it is due, while it is still the one armed, until the host answers. */
|
|
213
|
+
const send = async (id: string, pending = armed.get(id)) => {
|
|
214
|
+
// One request at a time: the one in flight sends again if it is not answered.
|
|
215
|
+
if (!pending || armed.get(id) !== pending || pending.sending) return;
|
|
216
|
+
const due = pending.at - Date.now();
|
|
217
|
+
if (due > 0) {
|
|
218
|
+
pending.timeout = setTimeout(() => void send(id, pending), Math.min(due, MAX_DELAY_MS)).unref();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
pending.sending = true;
|
|
222
|
+
const answered = await fetch(`${pending.url}/wake`).then(
|
|
223
|
+
(res) => res.ok,
|
|
224
|
+
() => false,
|
|
225
|
+
);
|
|
226
|
+
pending.sending = false;
|
|
227
|
+
if (armed.get(id) !== pending) return;
|
|
228
|
+
if (answered) armed.delete(id);
|
|
229
|
+
else pending.timeout = setTimeout(() => void send(id, pending), RETRY_MS).unref();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const api = (state: FakeWakeupState): WakeupApi => ({
|
|
233
|
+
async put(url, token) {
|
|
234
|
+
const checked = checkedUrl(url);
|
|
235
|
+
if (!INSTANCE_TOKEN.test(token)) throw fail("token must be an instance's token", 400);
|
|
236
|
+
const mine = Object.values(state.instances).find((i) => i.token === token);
|
|
237
|
+
if (mine) {
|
|
238
|
+
// The same instance, moved: its wake there forgotten.
|
|
239
|
+
if (mine.url !== checked) {
|
|
240
|
+
Object.assign(mine, { url: checked, ...UNREGISTERED });
|
|
241
|
+
disarm(mine.id);
|
|
242
|
+
}
|
|
243
|
+
return { id: mine.id };
|
|
244
|
+
}
|
|
245
|
+
const id = `00000000-0000-4000-8000-${String(++state.seq).padStart(12, "0")}`;
|
|
246
|
+
state.instances[id] = { id, url: checked, ...UNREGISTERED, token };
|
|
247
|
+
return { id };
|
|
248
|
+
},
|
|
249
|
+
async get(id) {
|
|
250
|
+
const found = state.instances[id];
|
|
251
|
+
if (!found) return undefined;
|
|
252
|
+
const { token: _, ...shown } = found;
|
|
253
|
+
return shown;
|
|
254
|
+
},
|
|
255
|
+
async remove(id) {
|
|
256
|
+
delete state.instances[id];
|
|
257
|
+
disarm(id);
|
|
258
|
+
},
|
|
259
|
+
async wake({ at }, token) {
|
|
260
|
+
// The token names the instance.
|
|
261
|
+
const mine = Object.values(state.instances).find((i) => i.token === token);
|
|
262
|
+
if (!mine) throw fail("the token is no instance's", 401);
|
|
263
|
+
Object.assign(mine, { at: new Date(at).toISOString(), lastError: null });
|
|
264
|
+
disarm(mine.id);
|
|
265
|
+
armed.set(mine.id, { at: Date.parse(at), url: mine.url });
|
|
266
|
+
void send(mine.id);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const fake = defineFake(
|
|
271
|
+
wakeupOps,
|
|
272
|
+
{
|
|
273
|
+
initial: (): FakeWakeupState => ({ seq: 0, instances: {} }),
|
|
274
|
+
ops: (state) => ({ wakeup: wakeupCalls(() => api(state)) }),
|
|
275
|
+
},
|
|
276
|
+
options,
|
|
277
|
+
);
|
|
278
|
+
const close = () => {
|
|
279
|
+
for (const id of armed.keys()) disarm(id);
|
|
280
|
+
};
|
|
281
|
+
const { reset } = fake;
|
|
282
|
+
return Object.assign(fake, {
|
|
283
|
+
/** Sends every armed wake now, whatever its `at`; resolves once the hosts answered them, or fails after 60 s. */
|
|
284
|
+
async fire() {
|
|
285
|
+
const fired = [...armed].map(([id, pending]) => {
|
|
286
|
+
clearTimeout(pending.timeout);
|
|
287
|
+
pending.at = 0;
|
|
288
|
+
void send(id, pending);
|
|
289
|
+
return pending;
|
|
290
|
+
});
|
|
291
|
+
const until = Date.now() + 60_000;
|
|
292
|
+
while (fired.some((p) => [...armed.values()].includes(p))) {
|
|
293
|
+
if (Date.now() > until) throw new Error("sanoma.wakeup fake: a fired wake was not answered in 60 s");
|
|
294
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
/** Disarms every armed wake. */
|
|
298
|
+
close,
|
|
299
|
+
/** Empties the state and disarms every armed wake. */
|
|
300
|
+
reset() {
|
|
301
|
+
close();
|
|
302
|
+
reset();
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -7,14 +7,13 @@ import type { z } from "zod";
|
|
|
7
7
|
/**
|
|
8
8
|
* How a host keeps the runtime up:
|
|
9
9
|
* - `long-lived`: the process is restarted if it exits, and never stopped for being quiet (Docker).
|
|
10
|
-
* - `stops-on-exit`: the host stops the instance when its process exits, and starts it again on
|
|
11
|
-
* the next request (Fly).
|
|
12
10
|
* - `stops-when-quiet`: the host stops the instance after a while without inbound requests, and
|
|
13
11
|
* starts it again on the next request (a free Render service, Cloudflare Containers).
|
|
14
12
|
*
|
|
15
13
|
* A host that stops needs a declared `Wakeup` for what is due at a time while it is stopped.
|
|
14
|
+
* Later: `stops-on-exit`, with a host that stops the instance when its process exits (Fly).
|
|
16
15
|
*/
|
|
17
|
-
export type HostLifetime = "long-lived" | "stops-
|
|
16
|
+
export type HostLifetime = "long-lived" | "stops-when-quiet";
|
|
18
17
|
|
|
19
18
|
/** What a host can do, for choosing one. */
|
|
20
19
|
export interface HostCapabilities {
|
|
@@ -62,7 +61,7 @@ export interface HostSpec {
|
|
|
62
61
|
outputs: (state: Record<string, unknown>) => HostOutputs;
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-
|
|
64
|
+
const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-when-quiet"];
|
|
66
65
|
|
|
67
66
|
/** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
|
|
68
67
|
export function hasPath(schema: z.ZodType, path: string): boolean {
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,9 @@ export {
|
|
|
61
61
|
type ResolvedConfig,
|
|
62
62
|
type SanomaConfig,
|
|
63
63
|
} from "./config.ts";
|
|
64
|
+
// An app's `app.ts` (`defineApp`), and where the launcher finds an app's workflows and data file.
|
|
65
|
+
export { type AppDefinition, type AppEntry, defineApp, readApps } from "./apps.ts";
|
|
66
|
+
export { appFolders, APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
|
|
64
67
|
// `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
|
|
65
68
|
// `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
|
|
66
69
|
export { isCollection } from "./collection.ts";
|
|
@@ -76,12 +79,15 @@ export {
|
|
|
76
79
|
} from "./apply.ts";
|
|
77
80
|
export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
|
|
78
81
|
export {
|
|
82
|
+
type ChangeDecision,
|
|
83
|
+
type ChangeDeploy,
|
|
79
84
|
type ChangeEvent,
|
|
80
85
|
ChangeFacts,
|
|
81
86
|
ChangeInput,
|
|
82
87
|
changeOps,
|
|
83
88
|
type ChangeReport,
|
|
84
89
|
type ChangeState,
|
|
90
|
+
type ChangeVerified,
|
|
85
91
|
runtimeHold,
|
|
86
92
|
touchesOf,
|
|
87
93
|
} from "./change.ts";
|
|
@@ -125,6 +131,7 @@ export {
|
|
|
125
131
|
ApprovalPendingError,
|
|
126
132
|
errorCode,
|
|
127
133
|
type ErrorCode,
|
|
134
|
+
vendorCodeOf,
|
|
128
135
|
type ErrorInfo,
|
|
129
136
|
type InputIssue,
|
|
130
137
|
invalidInput,
|
|
@@ -149,8 +156,6 @@ export type { RunArgs } from "./run.ts";
|
|
|
149
156
|
export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
|
|
150
157
|
export { superviseWorker, type Supervisor, type SupervisorOptions } from "./supervisor.ts";
|
|
151
158
|
export type { DeadlineSource, Deadlines, SelfStop, Wait } from "./deadlines.ts";
|
|
152
|
-
// Whether a wake is the one the instance's worker last registered, for the `/wake` route.
|
|
153
|
-
export { shouldWake } from "./db.ts";
|
|
154
159
|
export { nextFire, nextScheduledFire } from "./schedules.ts";
|
|
155
160
|
// `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
|
|
156
161
|
export {
|
|
@@ -163,12 +168,23 @@ export {
|
|
|
163
168
|
type Secrets,
|
|
164
169
|
} from "./secret.ts";
|
|
165
170
|
export {
|
|
171
|
+
type AppMerge,
|
|
166
172
|
type CheckResult,
|
|
167
173
|
type Draft,
|
|
174
|
+
draftBranch,
|
|
168
175
|
type DraftDiff,
|
|
176
|
+
draftOf,
|
|
177
|
+
type HostEnforcement,
|
|
178
|
+
type PullRequest,
|
|
179
|
+
type RemoteContext,
|
|
180
|
+
type RemoteEvent,
|
|
181
|
+
type RemoteHalf,
|
|
182
|
+
remoteFor,
|
|
183
|
+
type RepoRef,
|
|
169
184
|
resolveRevision,
|
|
170
185
|
SOURCE_RESOLVE,
|
|
171
186
|
type Source,
|
|
187
|
+
type SourceHost,
|
|
172
188
|
} from "./source.ts";
|
|
173
189
|
export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
|
|
174
190
|
export {
|
|
@@ -186,11 +202,12 @@ export {
|
|
|
186
202
|
type WakeupOutputs,
|
|
187
203
|
type WakeupRegistration,
|
|
188
204
|
type WakeupSpec,
|
|
189
|
-
wakeGeneration,
|
|
190
205
|
wakeupHostTypes,
|
|
191
206
|
} from "./wakeup.ts";
|
|
192
207
|
export {
|
|
193
208
|
type CallOptions,
|
|
209
|
+
type ChangesFilter,
|
|
210
|
+
type ChangeView,
|
|
194
211
|
type ClientOptions,
|
|
195
212
|
SanomaClient,
|
|
196
213
|
type RunsFilter,
|
|
@@ -212,12 +229,14 @@ export {
|
|
|
212
229
|
CHANGE_MERGE,
|
|
213
230
|
CHANGE_OPEN,
|
|
214
231
|
CHANGE_REVIEW,
|
|
232
|
+
CHANGE_TOPIC,
|
|
215
233
|
CHANGE_WORKFLOW,
|
|
216
234
|
declaresPeople,
|
|
217
235
|
type PeopleResource,
|
|
218
236
|
personOf,
|
|
219
237
|
principalOf,
|
|
220
238
|
RUNTIME_AGENTS,
|
|
239
|
+
HOST_CHECK_AGENT,
|
|
221
240
|
SCHEDULE_AGENT,
|
|
222
241
|
credentialReady,
|
|
223
242
|
DEFAULT_SLEEP_NAME,
|
package/src/lint.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
3
|
import { byPosition, childrenOf, type LintProblem, type Node, parsed, type SourceFile } from "./ast.ts";
|
|
4
4
|
import { COLLECTIONS_FILE, readDataFile } from "./datafile.ts";
|
|
5
5
|
import { constsOf, outlineBody, workflowLiterals } from "./outline.ts";
|
|
6
|
-
import { inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
6
|
+
import { APPS_DIR, inside, nearestDir, RELATIVE } from "./paths.ts";
|
|
7
7
|
// From src/ and from dist/ alike, the package's own oxlint.json: the one list of allowed names.
|
|
8
8
|
import oxlint from "../oxlint.json" with { type: "json" };
|
|
9
9
|
|
|
@@ -29,7 +29,7 @@ const ALLOWED_PACKAGE = new RegExp(`^(${[...AUTHOR_PACKAGES, "@sanoma-ai/connect
|
|
|
29
29
|
const REFUSED_PACKAGES: [RegExp, string][] = [
|
|
30
30
|
[/^@sanoma-ai\/testing(\/|$)/, `fakes and test helpers belong in tests; ${THROUGH_CTX}`],
|
|
31
31
|
[/^@sanoma-ai\/app(\/|$)/, "the app starts runs and decides approvals, so a workflow could approve itself"],
|
|
32
|
-
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
32
|
+
[/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
|
|
33
33
|
[/^sanoma\//, "`sanoma` has no entry points for a workflow: import its names from `sanoma` itself"],
|
|
34
34
|
];
|
|
35
35
|
|
|
@@ -203,11 +203,14 @@ function whyRefused(spec: string, filename: string | undefined, root: string | u
|
|
|
203
203
|
const target = join(dirname(filename), spec);
|
|
204
204
|
if (inside(root, target)) return undefined;
|
|
205
205
|
const tree = basename(root) === "workflows";
|
|
206
|
-
// The collections, which a workflow names in `uses`: the one data file the runtime imports too
|
|
207
|
-
|
|
206
|
+
// The collections, which a workflow names in `uses`: the one data file the runtime imports too,
|
|
207
|
+
// the root's or an app's (`apps/<app>/resources/data.ts`), wherever the workflow is.
|
|
208
|
+
if (tree && isCollectionsFile(realPath(target), realPath(dirname(root)))) return undefined;
|
|
208
209
|
return (
|
|
209
210
|
`it reaches outside ${tree ? `${basename(root)}/` : "this directory"}, toward the config and its drivers; ${THROUGH_CTX}` +
|
|
210
|
-
(tree
|
|
211
|
+
(tree
|
|
212
|
+
? "; of the files outside it, only resources/data.ts (the collections, the root's or an app's apps/<app>/resources/data.ts) may be imported"
|
|
213
|
+
: "")
|
|
211
214
|
);
|
|
212
215
|
}
|
|
213
216
|
for (const [pattern, reason] of REFUSED_PACKAGES) if (pattern.test(spec)) return reason;
|
|
@@ -215,6 +218,18 @@ function whyRefused(spec: string, filename: string | undefined, root: string | u
|
|
|
215
218
|
return "import only from `sanoma` (or `@sanoma-ai/workflows`), a `@sanoma-ai/connector-*` package, zod, or a file beside this one";
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Whether `target` is a collections file of the company whose workflows' tree is in `base` (the
|
|
223
|
+
* root, or an app's `apps/<app>/`): `<root>/resources/data.ts` or `<root>/apps/<app>/resources/data.ts`.
|
|
224
|
+
*/
|
|
225
|
+
function isCollectionsFile(target: string, base: string): boolean {
|
|
226
|
+
const root = basename(dirname(base)) === APPS_DIR ? dirname(dirname(base)) : base;
|
|
227
|
+
const parts = relative(root, target).split(sep);
|
|
228
|
+
const [first, app] = parts;
|
|
229
|
+
const rest = first === APPS_DIR && app !== undefined ? parts.slice(2) : parts;
|
|
230
|
+
return rest.length === 2 && rest[0] === "resources" && rest[1] === COLLECTIONS_FILE;
|
|
231
|
+
}
|
|
232
|
+
|
|
218
233
|
/** A path's real path, or the path itself when it does not exist. */
|
|
219
234
|
function realPath(path: string): string {
|
|
220
235
|
try {
|
package/src/op.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ProviderV4 } from "@ai-sdk/provider";
|
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
import type { Fake } from "./fake.ts";
|
|
4
4
|
import type { Secrets } from "./secret.ts";
|
|
5
|
+
import type { RemoteHalf } from "./source.ts";
|
|
5
6
|
import type { Steps } from "./steps.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -308,7 +309,8 @@ export interface Driver {
|
|
|
308
309
|
* to call: the config then names the connector alone, and never a driver, a fake, steps or a
|
|
309
310
|
* login. Any may be missing: a workflow using an operation of a connector with no driver is
|
|
310
311
|
* refused, a connector with no fake has none for sandbox runs, one with no login half signs no
|
|
311
|
-
* visitor in,
|
|
312
|
+
* visitor in, one with no model half gives the app no models, and one with no remote half hosts
|
|
313
|
+
* no repository a change is reviewed on.
|
|
312
314
|
*/
|
|
313
315
|
export interface ConnectorSetup {
|
|
314
316
|
/** Makes the live driver, which reads its credentials from the environment when called: once per resolved config. */
|
|
@@ -331,6 +333,11 @@ export interface ConnectorSetup {
|
|
|
331
333
|
* and a missing one fails that call.
|
|
332
334
|
*/
|
|
333
335
|
model?: (context: ModelContext) => ProviderV4 | Promise<ProviderV4>;
|
|
336
|
+
/**
|
|
337
|
+
* Makes the connector's remote half, a git host's pull requests, statuses and webhooks: once per
|
|
338
|
+
* resolved config (`ResolvedConfig.remotes`). It reads its credentials when called.
|
|
339
|
+
*/
|
|
340
|
+
remote?: () => RemoteHalf;
|
|
334
341
|
}
|
|
335
342
|
|
|
336
343
|
/** What the app gives a connector's model half (`ConnectorSetup.model`): the secrets, as a login half reads them. */
|