@agentshouse/kit 0.1.0-alpha.26 → 0.1.0-alpha.28

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.
@@ -1,11 +1,17 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
  import WebSocket from "ws";
3
- import { KIT_CONTRACT_VERSION } from "./contract.js";
3
+ import {
4
+ KIT_CONTRACT_VERSION,
5
+ KIT_STREAM_RECONNECT_MAX_MS,
6
+ KIT_STREAM_RECONNECT_MS,
7
+ KIT_STREAM_REJOIN_MS,
8
+ } from "./contract.js";
4
9
  import { heldControl } from "./house.js";
5
10
  import { KitRefusal } from "./refusal.js";
6
11
 
7
12
  const PROTOCOL = "house.kit.stream.1";
8
- const WATCHED_SUBJECTS = ["agents", "working_copy"];
13
+ const WORK_SUBJECTS = ["runs", "commands", "agents", "working_copy", "sources"];
14
+ const FINAL_REFUSALS = ["kit_upgrade_required", "kit_stream_replaced"];
9
15
 
10
16
  function streamAddress(house) {
11
17
  const url = new URL("/kit/stream", house);
@@ -15,72 +21,88 @@ function streamAddress(house) {
15
21
  return url;
16
22
  }
17
23
 
18
- async function connected(session, signal, available, dependencies) {
24
+ function frameOf(data) {
25
+ try {
26
+ return JSON.parse(data.toString());
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ async function held(session, signal, heard, dependencies) {
19
33
  const authorization = await heldControl(session, signal);
20
34
  return new Promise((resolve, reject) => {
21
35
  const Socket = dependencies.workSocket ?? WebSocket;
22
36
  const socket = new Socket(streamAddress(session.house), PROTOCOL, {
23
37
  headers: { authorization, "x-house-kit-version": KIT_CONTRACT_VERSION },
24
38
  handshakeTimeout: 10_000,
25
- maxPayload: 4096,
26
39
  perMessageDeflate: false,
27
40
  });
28
41
  let finished = false;
29
- const finish = (error = null) => {
42
+ const finish = (reason = null, refusal = null) => {
30
43
  if (finished) return;
31
44
  finished = true;
32
45
  signal.removeEventListener("abort", stop);
33
- if (error === null) resolve();
34
- else reject(error);
46
+ if (refusal === null) resolve(reason);
47
+ else reject(refusal);
35
48
  };
36
49
  const stop = () => {
37
50
  socket.terminate();
38
51
  finish();
39
52
  };
40
53
  signal.addEventListener("abort", stop, { once: true });
41
- socket.on("open", () => available(null));
54
+ socket.on("open", () => heard.available(null));
42
55
  socket.on("message", (data) => {
43
- let frame;
44
- try {
45
- frame = JSON.parse(data.toString());
46
- } catch {
47
- return;
48
- }
49
- if (frame?.type === "work_available" && WATCHED_SUBJECTS.includes(frame.subject)) {
50
- available(frame.subject);
56
+ const frame = frameOf(data);
57
+ if (frame?.type === "work_available" && WORK_SUBJECTS.includes(frame.subject)) {
58
+ heard.available(frame.subject);
59
+ } else if (frame?.type === "entries") {
60
+ heard.changed();
51
61
  }
52
62
  });
53
63
  socket.on("unexpected-response", (_request, response) => {
54
64
  response.resume();
55
65
  if (response.statusCode === 409) session.dependencies.contractRefused?.();
56
- const refusal =
57
- response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null;
66
+ finish(null, response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null);
58
67
  socket.terminate();
59
- finish(refusal);
60
68
  });
61
69
  socket.on("error", () => {
62
70
  socket.terminate();
63
71
  finish();
64
72
  });
65
- socket.on("close", () => finish());
73
+ socket.on("close", (_code, reason) => {
74
+ const closed = reason?.toString() ?? "";
75
+ finish(closed, closed === "replaced" ? new KitRefusal("kit_stream_replaced") : null);
76
+ });
66
77
  if (signal.aborted) stop();
67
78
  });
68
79
  }
69
80
 
70
- export async function watchKitWork(session, signal, available, dependencies = {}) {
71
- let retryMs = 500;
81
+ export async function watchKitWork(session, signal, heard, dependencies = {}) {
82
+ const random = dependencies.random ?? Math.random;
83
+ const sleep = dependencies.streamSleep ?? ((milliseconds) => delay(milliseconds, undefined, { signal }));
84
+ let failures = 0;
72
85
  while (!signal.aborted) {
73
86
  const began = Date.now();
87
+ let reason = null;
74
88
  try {
75
- await connected(session, signal, available, dependencies);
89
+ reason = await held(session, signal, heard, dependencies);
76
90
  } catch (error) {
77
- if (error instanceof KitRefusal && error.code === "kit_upgrade_required") throw error;
78
- if (signal.aborted) return;
91
+ if (error instanceof KitRefusal && FINAL_REFUSALS.includes(error.code)) throw error;
79
92
  }
80
93
  if (signal.aborted) return;
81
- retryMs = Date.now() - began >= 30_000 ? 1000 : Math.min(retryMs * 2, 30_000);
94
+ let wait;
95
+ if (reason === "authority_changed") {
96
+ failures = 0;
97
+ wait = random() * KIT_STREAM_REJOIN_MS;
98
+ } else {
99
+ if (Date.now() - began >= KIT_STREAM_RECONNECT_MAX_MS) failures = 0;
100
+ const ceiling = Math.min(KIT_STREAM_RECONNECT_MAX_MS, KIT_STREAM_RECONNECT_MS * 2 ** failures);
101
+ failures += 1;
102
+ wait = ceiling / 2 + (random() * ceiling) / 2;
103
+ }
82
104
  try {
83
- await delay(dependencies.workReconnectMs ?? retryMs, undefined, { signal });
105
+ await sleep(wait);
84
106
  } catch (error) {
85
107
  if (!signal.aborted) throw error;
86
108
  }
package/src/setup.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  updateDeclaredAgent,
7
7
  } from "./agents.js";
8
8
  import { readConfiguration } from "./configuration.js";
9
- import { connectorLogin } from "./connector-login.js";
9
+ import { customAuthorizationStatus } from "./custom/authorization.js";
10
10
  import { GUIDED_LOGIN_TIMEOUT_MS } from "./contract.js";
11
11
  import { launchBase, prepareLaunchBase } from "./directory.js";
12
12
  import {
@@ -45,24 +45,14 @@ function agentModule(context, kind) {
45
45
  }
46
46
 
47
47
  function connectorModule(context, instance) {
48
- const gateway = connectorLogin(instance, context.dependencies);
49
48
  return {
50
49
  category: "connector",
51
50
  name: instance.id,
52
51
  login: { integration: instance.preset, instance: instance.id, service: instance.id },
53
52
  install: null,
54
53
  signedIn: () =>
55
- gateway.status(instance, { directory: context.directory, signal: context.signal }),
56
- guided:
57
- gateway.guided === undefined
58
- ? null
59
- : ({ present, signal }) =>
60
- gateway.guided(instance, {
61
- directory: context.directory,
62
- dependencies: context.dependencies,
63
- present,
64
- signal,
65
- }),
54
+ customAuthorizationStatus(instance, { directory: context.directory, signal: context.signal }),
55
+ guided: null,
66
56
  };
67
57
  }
68
58
 
@@ -144,8 +134,12 @@ async function setUpModule(context, module, install = true) {
144
134
  return summary("ready");
145
135
  }
146
136
 
137
+ export function signInModule(context, module) {
138
+ return setUpModule(context, module, false);
139
+ }
140
+
147
141
  export function signInWantedAgent(context, kind) {
148
- return setUpModule(context, agentModule(context, kind), false);
142
+ return signInModule(context, agentModule(context, kind));
149
143
  }
150
144
 
151
145
  export async function runSetup(context, request) {
package/src/source.js CHANGED
@@ -13,7 +13,8 @@ import {
13
13
  stallOf,
14
14
  } from "./delivery.js";
15
15
  import { captureBatch, maintainControl, openSession } from "./house.js";
16
- import { googlePage } from "./google/source.js";
16
+ import { GOOGLE_SOURCES } from "./google/oauth.js";
17
+ import { googlePage, recordAccountEmail } from "./google/source.js";
17
18
  import { houseAnswered, houseAnsweredFailure, houseFailed, KitRefusal } from "./refusal.js";
18
19
  import { recordAcceptedOriginals, sameReference, saveSourceOriginals } from "./source-originals.js";
19
20
  import {
@@ -29,14 +30,15 @@ import {
29
30
  writeCheckpoint,
30
31
  } from "./state.js";
31
32
 
33
+ const GOOGLE = Object.freeze({ id: GOOGLE_SOURCES, preset: "google" });
34
+
32
35
  async function acquire(context, service, checkpoint, limit) {
33
- if (context.instance.preset === "google") {
36
+ if (service.google !== undefined) {
34
37
  return googlePage(
35
38
  {
36
39
  directory: context.directory,
37
- instance: context.instance.id,
38
- client: context.googleOAuthClient,
39
- service,
40
+ account: service.google.account,
41
+ service: service.google.service,
40
42
  cursor: checkpoint.cursor,
41
43
  limit,
42
44
  backfillFloor:
@@ -169,6 +171,7 @@ async function deliverService(context, service) {
169
171
  context.instance.preset === "custom"
170
172
  ? { ...context, environment: await resolveEnvironment(service.connector) }
171
173
  : context;
174
+ if (service.google !== undefined) await recordAccountEmail(context, service.google);
172
175
  await runDeliveryLoop(resolved, service, progress);
173
176
  } catch (refusal) {
174
177
  if (!(refusal instanceof KitRefusal) || refusal.code === "kit_command_cancelled") throw refusal;
@@ -197,12 +200,31 @@ async function deliverService(context, service) {
197
200
  };
198
201
  }
199
202
 
203
+ function initialBackfill(initial) {
204
+ if (initial.new === true) return { kind: "days", bound: 0 };
205
+ if (initial.days !== undefined) return { kind: "days", bound: initial.days };
206
+ if (initial.items !== undefined) return { kind: "count", bound: initial.items };
207
+ return { kind: "all" };
208
+ }
209
+
210
+ export async function captureEntry(context, entry) {
211
+ const delivered = await deliverService(
212
+ { ...context, instance: GOOGLE },
213
+ {
214
+ service: entry.source,
215
+ sourceRef: entry.source,
216
+ rooms: entry.rooms,
217
+ backfill: initialBackfill(entry.initial),
218
+ google: { account: entry.account, service: entry.service },
219
+ },
220
+ );
221
+ return { ...delivered, service: entry.service };
222
+ }
223
+
200
224
  export async function captureSource(options, dependencies = {}) {
201
225
  const configuration = await readConfiguration(options.configuration);
202
226
  const instance = sourceInstance(configuration, options.instance);
203
227
  const services = declaredServices(instance, options.service ?? null);
204
- const googleOAuthClient =
205
- instance.preset === "google" ? dependencies.googleOAuthClient() : null;
206
228
  const session = await openSession(options, dependencies);
207
229
  return maintainControl(
208
230
  session,
@@ -214,7 +236,6 @@ export async function captureSource(options, dependencies = {}) {
214
236
  signal,
215
237
  now: session.now,
216
238
  dependencies,
217
- googleOAuthClient,
218
239
  };
219
240
  const delivered = [];
220
241
  for (const service of services) delivered.push(await deliverService(context, service));
@@ -224,6 +245,27 @@ export async function captureSource(options, dependencies = {}) {
224
245
  );
225
246
  }
226
247
 
248
+ export async function localServiceState(directory, instance, service) {
249
+ return {
250
+ health: (await readHealth(directory, instance, service)) ?? {
251
+ state: "pending",
252
+ category: null,
253
+ observed_at: null,
254
+ },
255
+ checkpoint: await readCheckpoint(directory, instance, service),
256
+ gaps: await readCoverageGaps(directory, instance, service),
257
+ dropped: await readDroppedItems(directory, instance, service),
258
+ originals: (await readSourceOriginals(directory, instance, service)).map((reference) => ({
259
+ room: reference.room,
260
+ record: reference.record,
261
+ position: reference.position,
262
+ native_object_id: reference.native_object_id,
263
+ save: reference.save,
264
+ error: reference.error,
265
+ })),
266
+ };
267
+ }
268
+
227
269
  export async function sourceStatus(options) {
228
270
  const configuration = await readConfiguration(options.configuration);
229
271
  const instance = sourceInstance(configuration, options.instance);
@@ -234,24 +276,7 @@ export async function sourceStatus(options) {
234
276
  service: service.service,
235
277
  source_ref: service.sourceRef,
236
278
  rooms: service.rooms,
237
- health: (await readHealth(options.directory, instance.id, service.service)) ?? {
238
- state: "pending",
239
- category: null,
240
- observed_at: null,
241
- },
242
- checkpoint: await readCheckpoint(options.directory, instance.id, service.service),
243
- gaps: await readCoverageGaps(options.directory, instance.id, service.service),
244
- dropped: await readDroppedItems(options.directory, instance.id, service.service),
245
- originals: (await readSourceOriginals(options.directory, instance.id, service.service)).map(
246
- (reference) => ({
247
- room: reference.room,
248
- record: reference.record,
249
- position: reference.position,
250
- native_object_id: reference.native_object_id,
251
- save: reference.save,
252
- error: reference.error,
253
- }),
254
- ),
279
+ ...(await localServiceState(options.directory, instance.id, service.service)),
255
280
  });
256
281
  }
257
282
  return { instance: instance.id, preset: instance.preset, services: reported };
package/src/state.js CHANGED
@@ -9,10 +9,28 @@ function segment(name) {
9
9
  return name;
10
10
  }
11
11
 
12
- function notePath(directory, instance, service, kind) {
12
+ export function notePath(directory, instance, service, kind) {
13
13
  return join(directory, "state", segment(instance), kind, `${segment(service)}.json`);
14
14
  }
15
15
 
16
+ export async function noteNames(directory, instance, kind) {
17
+ let present;
18
+ try {
19
+ present = await readdir(join(directory, "state", segment(instance), kind));
20
+ } catch (error) {
21
+ if (error.code === "ENOENT") return [];
22
+ throw new KitRefusal("kit_state_unreadable", kind);
23
+ }
24
+ return present
25
+ .filter((name) => name.endsWith(".json"))
26
+ .map((name) => name.slice(0, -".json".length))
27
+ .sort();
28
+ }
29
+
30
+ export async function removeNotes(directory, instance, service, kinds) {
31
+ for (const kind of kinds) await rm(notePath(directory, instance, service, kind), { force: true });
32
+ }
33
+
16
34
  function repositoryPath(directory, key) {
17
35
  if (!/^[a-f0-9]{64}$/.test(key)) throw new KitRefusal("kit_state_name_invalid", key);
18
36
  return join(directory, "state", "repositories", `${key}.json`);
package/src/tasks.js ADDED
@@ -0,0 +1,75 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ export function coalescedTask(signal, fail, work) {
4
+ let pending = false;
5
+ let task = null;
6
+ const trigger = () => {
7
+ pending = true;
8
+ if (task !== null) return;
9
+ task = (async () => {
10
+ while (pending && !signal.aborted) {
11
+ pending = false;
12
+ await work();
13
+ }
14
+ })()
15
+ .catch((error) => {
16
+ if (signal.aborted) return;
17
+ fail(error);
18
+ })
19
+ .finally(() => {
20
+ task = null;
21
+ if (pending && !signal.aborted) trigger();
22
+ });
23
+ };
24
+ return { trigger, idle: () => task === null, settled: () => task };
25
+ }
26
+
27
+ export function residentWake() {
28
+ let pending = false;
29
+ let release = null;
30
+ return {
31
+ notify() {
32
+ pending = true;
33
+ release?.();
34
+ },
35
+ async wait(milliseconds, signal) {
36
+ if (!pending && !signal.aborted) {
37
+ const pause = new AbortController();
38
+ const paused = AbortSignal.any([signal, pause.signal]);
39
+ try {
40
+ await Promise.race([
41
+ new Promise((resolve) => {
42
+ release = resolve;
43
+ }),
44
+ Number.isFinite(milliseconds)
45
+ ? delay(milliseconds, undefined, { signal: paused })
46
+ : new Promise((resolve) => paused.addEventListener("abort", resolve, { once: true })),
47
+ ]);
48
+ } catch (error) {
49
+ if (!signal.aborted) throw error;
50
+ } finally {
51
+ release = null;
52
+ pause.abort();
53
+ }
54
+ }
55
+ const woken = pending;
56
+ pending = false;
57
+ return woken;
58
+ },
59
+ };
60
+ }
61
+
62
+ const turns = new Map();
63
+
64
+ export function exclusively(path, work) {
65
+ const turn = (turns.get(path) ?? Promise.resolve()).then(work);
66
+ const settled = turn.then(
67
+ () => undefined,
68
+ () => undefined,
69
+ );
70
+ turns.set(path, settled);
71
+ void settled.then(() => {
72
+ if (turns.get(path) === settled) turns.delete(path);
73
+ });
74
+ return turn;
75
+ }