@sanoma-ai/workflows 0.7.0 → 0.7.1
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 +9 -5
- package/dist/config.d.ts +31 -6
- package/dist/config.js +36 -10
- 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 +9 -21
- package/dist/db.js +16 -54
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +13 -10
- package/dist/deadlines.js +18 -24
- package/dist/deadlines.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 +2 -3
- package/dist/index.js +2 -4
- package/dist/index.js.map +1 -1
- package/dist/lint.js +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +8 -1
- package/dist/op.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.js +5 -3
- package/dist/resources.js.map +1 -1
- package/dist/source.d.ts +124 -0
- package/dist/source.js +9 -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/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 +1 -5
- package/dist/worker.js.map +1 -1
- package/oxlint.json +1 -0
- package/package.json +3 -1
- package/src/config.ts +45 -14
- package/src/constructors.ts +8 -2
- package/src/db.ts +16 -66
- package/src/deadlines.ts +22 -28
- package/src/fake.ts +137 -0
- package/src/host.ts +3 -4
- package/src/index.ts +8 -3
- package/src/lint.ts +1 -1
- package/src/op.ts +8 -1
- package/src/person.ts +4 -1
- package/src/resources.ts +5 -3
- package/src/source.ts +118 -0
- package/src/supervisor.ts +7 -12
- 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 +3 -7
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
|
@@ -149,8 +149,6 @@ export type { RunArgs } from "./run.ts";
|
|
|
149
149
|
export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
|
|
150
150
|
export { superviseWorker, type Supervisor, type SupervisorOptions } from "./supervisor.ts";
|
|
151
151
|
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
152
|
export { nextFire, nextScheduledFire } from "./schedules.ts";
|
|
155
153
|
// `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
|
|
156
154
|
export {
|
|
@@ -166,9 +164,17 @@ export {
|
|
|
166
164
|
type CheckResult,
|
|
167
165
|
type Draft,
|
|
168
166
|
type DraftDiff,
|
|
167
|
+
type HostEnforcement,
|
|
168
|
+
type PullRequest,
|
|
169
|
+
type RemoteContext,
|
|
170
|
+
type RemoteEvent,
|
|
171
|
+
type RemoteHalf,
|
|
172
|
+
remoteFor,
|
|
173
|
+
type RepoRef,
|
|
169
174
|
resolveRevision,
|
|
170
175
|
SOURCE_RESOLVE,
|
|
171
176
|
type Source,
|
|
177
|
+
type SourceHost,
|
|
172
178
|
} from "./source.ts";
|
|
173
179
|
export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
|
|
174
180
|
export {
|
|
@@ -186,7 +192,6 @@ export {
|
|
|
186
192
|
type WakeupOutputs,
|
|
187
193
|
type WakeupRegistration,
|
|
188
194
|
type WakeupSpec,
|
|
189
|
-
wakeGeneration,
|
|
190
195
|
wakeupHostTypes,
|
|
191
196
|
} from "./wakeup.ts";
|
|
192
197
|
export {
|
package/src/lint.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/person.ts
CHANGED
|
@@ -68,8 +68,11 @@ const group = defineResource({
|
|
|
68
68
|
*/
|
|
69
69
|
export const sanoma = { person, agent, group };
|
|
70
70
|
|
|
71
|
+
/** The runtime's own vendor, as a data file imports its constructors: `import { sanoma } from "sanoma/resources"`. */
|
|
72
|
+
export const SANOMA_INFO = { title: "Sanoma", package: "sanoma" };
|
|
73
|
+
|
|
71
74
|
/**
|
|
72
75
|
* The runtime's own connector of the people types: every config's, so its data files may declare
|
|
73
76
|
* them. Its package is `sanoma`, whose `/resources` entry exports `sanoma`.
|
|
74
77
|
*/
|
|
75
|
-
export const peopleConnector = defineConnector("sanoma", sanoma,
|
|
78
|
+
export const peopleConnector = defineConnector("sanoma", sanoma, SANOMA_INFO);
|
package/src/resources.ts
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
import { type Binding, type Bindings, LOCKFILE, readLockfile } from "./lockfile.ts";
|
|
13
13
|
import { type Connector, VENDOR } from "./op.ts";
|
|
14
14
|
import { filesUnder } from "./paths.ts";
|
|
15
|
-
import { peopleConnector } from "./person.ts";
|
|
15
|
+
import { peopleConnector, sanoma } from "./person.ts";
|
|
16
|
+
import { wakeupOps } from "./wakeup-type.ts";
|
|
16
17
|
import type { Span } from "./outline.ts";
|
|
17
18
|
import { type Declared, type Resource, resourceTypesOf } from "./resource.ts";
|
|
18
19
|
import { Principal } from "./define.ts";
|
|
@@ -144,8 +145,9 @@ export function readPeople(root: string | undefined): DeclaredPeople {
|
|
|
144
145
|
const stamp = filesStamp(root);
|
|
145
146
|
const cached = peopleRead.get(root);
|
|
146
147
|
if (cached?.stamp === stamp) return cached.read;
|
|
147
|
-
|
|
148
|
-
const
|
|
148
|
+
// With the wakeup's type, which the same constructors declare: read, and only the people's kept.
|
|
149
|
+
const read = readDataFiles(root, [peopleConnector, wakeupOps], undefined, (source) => DECLARES_PEOPLE.test(source));
|
|
150
|
+
const people = read.resources.filter((r) => Object.hasOwn(sanoma, r.type));
|
|
149
151
|
// The people are never bound, so the lockfile's problems are not theirs.
|
|
150
152
|
const own = read.problems.filter((p) => p.file !== LOCKFILE);
|
|
151
153
|
const ids = new Map<string, DeclaredResource>();
|
package/src/source.ts
CHANGED
|
@@ -135,3 +135,121 @@ export async function resolveRevision(url: string, ref: string): Promise<string>
|
|
|
135
135
|
}
|
|
136
136
|
throw new DriverError(`${url} has no branch or tag ${ref}`, { retryable: false, vendorCode: "no_such_ref" });
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
// A git host's side of a change: the pull request a branch is reviewed in, merged through and
|
|
140
|
+
// closed by, the `sanoma` commit status the review card links from, whether the host refuses a
|
|
141
|
+
// change to `main` that is not a merge, and the host's webhooks. A connector carries it as its
|
|
142
|
+
// remote half (`ConnectorSetup.remote`), the way a login or a model half is carried.
|
|
143
|
+
|
|
144
|
+
/** A repository on a host: `owner/name`. */
|
|
145
|
+
export interface RepoRef {
|
|
146
|
+
owner: string;
|
|
147
|
+
name: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A pull request (a merge request, on a host that calls it so), as a change follows it. */
|
|
151
|
+
export interface PullRequest {
|
|
152
|
+
number: number;
|
|
153
|
+
/** Its page on the host. */
|
|
154
|
+
url: string;
|
|
155
|
+
/** The branch it merges. */
|
|
156
|
+
branch: string;
|
|
157
|
+
/** The branch's commit it was read at. */
|
|
158
|
+
head: string;
|
|
159
|
+
state: "open" | "closed" | "merged";
|
|
160
|
+
/** The commit its merge made on the base branch, once merged. */
|
|
161
|
+
mergeSha?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whether the host refuses a change to a branch that does not follow the rules: `pullRequest`
|
|
166
|
+
* when a direct push is refused (every change is a merge), `check` when a merge needs the
|
|
167
|
+
* `sanoma` status to pass. `reason` says why neither can be read or held, such as a plan that
|
|
168
|
+
* has no rules.
|
|
169
|
+
*/
|
|
170
|
+
export interface HostEnforcement {
|
|
171
|
+
pullRequest: boolean;
|
|
172
|
+
check: boolean;
|
|
173
|
+
reason?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One repository on a host, opened by its remote half with the company's credentials, read when
|
|
178
|
+
* a call needs them; a half may keep a token for a few minutes (its README says how long), so a
|
|
179
|
+
* token stored later is used soon after. A failure is a `DriverError` (retryable as the host's
|
|
180
|
+
* status says).
|
|
181
|
+
*/
|
|
182
|
+
export interface SourceHost {
|
|
183
|
+
/**
|
|
184
|
+
* A token that writes to the repository: the company's app's on the host when it is installed
|
|
185
|
+
* on the owner, else a person's token (`prefer: "token"` asks for that one first). It is never
|
|
186
|
+
* logged; a push signs in with it. A `DriverError` whose `vendorCode` is `no_token` when there is
|
|
187
|
+
* neither.
|
|
188
|
+
*/
|
|
189
|
+
token(prefer?: "app" | "token"): Promise<string>;
|
|
190
|
+
/** The pull request of `branch`: the open one, else the latest; undefined when it has none. */
|
|
191
|
+
pullRequestFor(branch: string): Promise<PullRequest | undefined>;
|
|
192
|
+
pullRequest(number: number): Promise<PullRequest>;
|
|
193
|
+
/** Opens a pull request of `branch` into `base`; the open one when `branch` has one already. */
|
|
194
|
+
openPullRequest(request: { branch: string; base: string; title: string; body: string }): Promise<PullRequest>;
|
|
195
|
+
/**
|
|
196
|
+
* Squash-merges pull request `number` when its branch is still at `sha`, as one commit titled
|
|
197
|
+
* `title`. A `DriverError` whose `vendorCode` is `head_moved` when the branch moved on, and
|
|
198
|
+
* `not_mergeable` when the host refuses the merge (a conflict, a rule).
|
|
199
|
+
*/
|
|
200
|
+
mergePullRequest(number: number, merge: { sha: string; method: "squash"; title?: string }): Promise<{ sha: string }>;
|
|
201
|
+
closePullRequest(number: number): Promise<void>;
|
|
202
|
+
/** Deletes `branch`; nothing when it is gone already. */
|
|
203
|
+
deleteBranch?(branch: string): Promise<void>;
|
|
204
|
+
/** Sets the `sanoma` status on commit `sha`, linking to `targetUrl`. */
|
|
205
|
+
setStatus(status: {
|
|
206
|
+
sha: string;
|
|
207
|
+
state: "pending" | "success" | "failure";
|
|
208
|
+
description: string;
|
|
209
|
+
targetUrl?: string;
|
|
210
|
+
}): Promise<void>;
|
|
211
|
+
/** What the host enforces on `base` (see `HostEnforcement`). */
|
|
212
|
+
enforcement(base: string): Promise<HostEnforcement>;
|
|
213
|
+
/** Uploads a PNG the pull request's comment shows; its URL. */
|
|
214
|
+
uploadImage?(png: Uint8Array, name: string): Promise<string>;
|
|
215
|
+
/** Writes the pull request's comment that carries `marker`, once: a second call edits it. */
|
|
216
|
+
upsertComment?(number: number, marker: string, body: string): Promise<void>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** What a host's webhook says happened: a branch pushed, or a pull request closed (merged or not). */
|
|
220
|
+
export type RemoteEvent =
|
|
221
|
+
| { kind: "pushed"; repo: RepoRef; branch: string; head: string }
|
|
222
|
+
| { kind: "closed"; repo: RepoRef; branch: string; number: number; merged: boolean; sha?: string };
|
|
223
|
+
|
|
224
|
+
/** What a remote half reads the company's credentials with, as a login or a model half reads them. */
|
|
225
|
+
export interface RemoteContext {
|
|
226
|
+
/** Secrets' values by name: the environment's first, then the stored credentials (`LoginContext.secrets`). */
|
|
227
|
+
secrets(names: readonly string[]): Promise<Map<string, string>>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A connector's remote half (`ConnectorSetup.remote`): a git host's repositories, made once per resolved config. */
|
|
231
|
+
export interface RemoteHalf {
|
|
232
|
+
/** The repository a clone URL of this host names, or undefined for another host's. */
|
|
233
|
+
matches(url: string): RepoRef | undefined;
|
|
234
|
+
/** The repository `repo`, with the company's credentials. */
|
|
235
|
+
open(repo: RepoRef, context: RemoteContext): SourceHost;
|
|
236
|
+
/**
|
|
237
|
+
* The events a webhook request carries, once its signature is verified: throws for a missing or
|
|
238
|
+
* bad signature, and answers `[]` for an event the change workflow does not follow.
|
|
239
|
+
*/
|
|
240
|
+
webhook?(request: Request, context: RemoteContext): Promise<RemoteEvent[]>;
|
|
241
|
+
/** The accounts the company's app on the host is installed on: where it acts with no person's token. */
|
|
242
|
+
owners?(context: RemoteContext): Promise<{ login: string; kind: "user" | "organization" }[]>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The remote half among `remotes` (`ResolvedConfig.remotes`) whose host `url` is on, with its vendor and the repository. */
|
|
246
|
+
export function remoteFor(
|
|
247
|
+
remotes: ReadonlyMap<string, RemoteHalf>,
|
|
248
|
+
url: string,
|
|
249
|
+
): { vendor: string; remote: RemoteHalf; repo: RepoRef } | undefined {
|
|
250
|
+
for (const [vendor, remote] of remotes) {
|
|
251
|
+
const repo = remote.matches(url);
|
|
252
|
+
if (repo) return { vendor, remote, repo };
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
package/src/supervisor.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { shouldWake } from "./db.ts";
|
|
1
|
+
import type { SanomaConfig } from "./config.ts";
|
|
3
2
|
import type { SelfStop } from "./deadlines.ts";
|
|
4
3
|
import { warn } from "./log.ts";
|
|
5
4
|
import { errorMessage } from "./shared.ts";
|
|
@@ -21,10 +20,11 @@ export interface Supervisor {
|
|
|
21
20
|
*/
|
|
22
21
|
resume(): Promise<void>;
|
|
23
22
|
/**
|
|
24
|
-
* A wakeup's `GET /wake
|
|
25
|
-
*
|
|
23
|
+
* A wakeup's `GET /wake`: `resume` when the worker stopped itself; a no-op while one starts or
|
|
24
|
+
* runs. A wake that arrives late, twice or for a replaced registration starts a worker that
|
|
25
|
+
* finds nothing to do and stops again on its own check.
|
|
26
26
|
*/
|
|
27
|
-
wake(
|
|
27
|
+
wake(): Promise<void>;
|
|
28
28
|
/** Stops the worker running or starting, and starts none again. */
|
|
29
29
|
stop(): Promise<void>;
|
|
30
30
|
}
|
|
@@ -53,7 +53,6 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
|
|
|
53
53
|
let closed = false;
|
|
54
54
|
// A write while the worker starts or runs, since it began starting.
|
|
55
55
|
let heard = false;
|
|
56
|
-
let databaseUrl: string | undefined;
|
|
57
56
|
|
|
58
57
|
const launch = async () => {
|
|
59
58
|
heard = false;
|
|
@@ -64,7 +63,7 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
|
|
|
64
63
|
const selfStopped = (stopped: SelfStop) => {
|
|
65
64
|
worker = undefined;
|
|
66
65
|
if (closed) return;
|
|
67
|
-
// Resumed first, so `stopped` is false when `onStopped`
|
|
66
|
+
// Resumed first, so `stopped` is false when `onStopped` looks.
|
|
68
67
|
if (heard) void supervisor.resume();
|
|
69
68
|
onStopped?.(stopped);
|
|
70
69
|
};
|
|
@@ -89,11 +88,7 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
|
|
|
89
88
|
starting = undefined;
|
|
90
89
|
}));
|
|
91
90
|
},
|
|
92
|
-
|
|
93
|
-
if (!supervisor.stopped) return;
|
|
94
|
-
databaseUrl ??= resolveConfig(config).databaseUrl;
|
|
95
|
-
if (await shouldWake(databaseUrl, generation)) await supervisor.resume();
|
|
96
|
-
},
|
|
91
|
+
wake: () => (supervisor.stopped ? supervisor.resume() : Promise.resolve()),
|
|
97
92
|
async stop() {
|
|
98
93
|
closed = true;
|
|
99
94
|
await starting;
|