@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,7 +1,6 @@
1
1
  import { ITEM_BODY_MAX_BYTES } from "../contract.js";
2
2
  import { KitRefusal } from "../refusal.js";
3
3
  import { googleBoundedText, googleJson } from "./gateway.js";
4
- import { GOOGLE_DRIVE_ID } from "./id.js";
5
4
  import { GOOGLE_SCOPES } from "./oauth.js";
6
5
 
7
6
  const DRIVE_ORIGIN = "https://www.googleapis.com";
@@ -20,17 +19,10 @@ const TEXT_MIMES = new Set([
20
19
  "application/xhtml+xml",
21
20
  "image/svg+xml",
22
21
  ]);
23
- const FILE_FIELDS = "id,name,mimeType,modifiedTime,version,parents,webViewLink,owners,trashed";
24
- const FOLDER_FRONTIER_MAX = 4096;
22
+ const FILE_FIELDS =
23
+ "id,name,mimeType,modifiedTime,version,parents,webViewLink,owners,ownedByMe,trashed";
25
24
 
26
- function selectionsOf(selection) {
27
- return [
28
- ...selection.files.map((id) => ({ kind: "file", id })),
29
- ...selection.folders.map((id) => ({ kind: "folder", id })),
30
- ];
31
- }
32
-
33
- function cursorOf(value, selections) {
25
+ function cursorOf(value) {
34
26
  if (value === null) return null;
35
27
  let cursor;
36
28
  try {
@@ -40,29 +32,9 @@ function cursorOf(value, selections) {
40
32
  }
41
33
  const validSnapshot =
42
34
  cursor?.phase === "snapshot" &&
43
- Number.isInteger(cursor.selection) &&
44
- cursor.selection >= 0 &&
45
- cursor.selection < selections.length &&
46
35
  (cursor.pageToken === null || typeof cursor.pageToken === "string") &&
47
- typeof cursor.changeToken === "string" &&
48
- Array.isArray(cursor.folders) &&
49
- cursor.folders.every(
50
- (folder) => typeof folder === "string" && GOOGLE_DRIVE_ID.test(folder),
51
- ) &&
52
- Array.isArray(cursor.visited) &&
53
- cursor.visited.every(
54
- (folder) => typeof folder === "string" && GOOGLE_DRIVE_ID.test(folder),
55
- ) &&
56
- new Set([...cursor.folders, ...cursor.visited]).size ===
57
- cursor.folders.length + cursor.visited.length &&
58
- cursor.folders.length + cursor.visited.length <= FOLDER_FRONTIER_MAX &&
59
- (selections[cursor.selection].kind === "folder"
60
- ? cursor.folders.length > 0
61
- : cursor.folders.length === 0 && cursor.visited.length === 0);
62
- const validChanges =
63
- cursor?.phase === "changes" &&
64
- cursor.selection === 0 &&
65
- typeof cursor.pageToken === "string";
36
+ typeof cursor.changeToken === "string";
37
+ const validChanges = cursor?.phase === "changes" && typeof cursor.pageToken === "string";
66
38
  if (!validSnapshot && !validChanges) throw new KitRefusal("google_checkpoint_invalid", "drive");
67
39
  return cursor;
68
40
  }
@@ -70,8 +42,7 @@ function cursorOf(value, selections) {
70
42
  function common(options) {
71
43
  return {
72
44
  directory: options.directory,
73
- instance: options.instance,
74
- client: options.client,
45
+ account: options.account,
75
46
  requiredScopes: [GOOGLE_SCOPES.drive],
76
47
  signal: options.signal,
77
48
  };
@@ -86,14 +57,10 @@ function fileUrl(id, query = {}) {
86
57
  function fileMetadata(value) {
87
58
  if (
88
59
  typeof value?.id !== "string" ||
89
- !GOOGLE_DRIVE_ID.test(value.id) ||
90
60
  typeof value.name !== "string" ||
91
61
  typeof value.mimeType !== "string" ||
92
62
  (value.parents !== undefined &&
93
- (!Array.isArray(value.parents) ||
94
- value.parents.some(
95
- (parent) => typeof parent !== "string" || !GOOGLE_DRIVE_ID.test(parent),
96
- )))
63
+ (!Array.isArray(value.parents) || value.parents.some((parent) => typeof parent !== "string")))
97
64
  ) {
98
65
  throw new KitRefusal("google_provider_response_invalid", "drive file");
99
66
  }
@@ -102,7 +69,6 @@ function fileMetadata(value) {
102
69
 
103
70
  async function parentPath(options, file, dependencies, cache) {
104
71
  const names = [];
105
- const identifiers = [];
106
72
  const seen = new Set();
107
73
  let parent = file.parents?.[0] ?? null;
108
74
  while (parent !== null && parent !== "root") {
@@ -126,10 +92,9 @@ async function parentPath(options, file, dependencies, cache) {
126
92
  throw new KitRefusal("google_provider_response_invalid", "drive parent");
127
93
  }
128
94
  names.unshift(metadata.name);
129
- identifiers.unshift(metadata.id);
130
95
  parent = metadata.parents?.[0] ?? null;
131
96
  }
132
- return { names, identifiers };
97
+ return names;
133
98
  }
134
99
 
135
100
  async function contentOf(options, file, dependencies) {
@@ -190,7 +155,7 @@ async function normalizedFile(options, file, dependencies, cache) {
190
155
  author: authorOf(metadata),
191
156
  url: metadata.webViewLink ?? null,
192
157
  attachments: [],
193
- container: `google/drive${folders.names.length === 0 ? "" : `/${folders.names.join("/")}`}`,
158
+ container: `google/drive${folders.length === 0 ? "" : `/${folders.join("/")}`}`,
194
159
  };
195
160
  }
196
161
 
@@ -239,114 +204,45 @@ async function startPageToken(options, dependencies) {
239
204
  return answer.startPageToken;
240
205
  }
241
206
 
242
- async function snapshotPage(options, cursor, selections, dependencies) {
243
- const selected = selections[cursor.selection];
244
- let files;
245
- const dropped = [];
246
- let nextPageToken = null;
247
- if (selected.kind === "file") {
248
- try {
249
- files = [
250
- fileMetadata(
251
- await googleJson(
252
- {
253
- ...common(options),
254
- url: fileUrl(selected.id, { fields: FILE_FIELDS }),
255
- notFoundCode: "google_drive_item_missing",
256
- },
257
- dependencies,
258
- ),
259
- ),
260
- ];
261
- } catch (error) {
262
- if (!(error instanceof KitRefusal) || error.code !== "google_drive_item_missing") throw error;
263
- files = [];
264
- dropped.push({
265
- namespace: "google/drive",
266
- nativeObjectId: selected.id,
267
- nativeVersion: null,
268
- code: error.code,
269
- });
270
- }
271
- } else {
272
- const folder = cursor.folders[0];
273
- const url = new URL("/drive/v3/files", DRIVE_ORIGIN);
274
- url.searchParams.set("q", `'${folder}' in parents and trashed = false`);
275
- url.searchParams.set("pageSize", `${options.limit}`);
276
- url.searchParams.set("fields", `nextPageToken,files(${FILE_FIELDS})`);
277
- if (cursor.pageToken !== null) url.searchParams.set("pageToken", cursor.pageToken);
278
- const answer = await googleJson({ ...common(options), url: url.toString() }, dependencies);
279
- if (
280
- !Array.isArray(answer.files) ||
281
- (answer.nextPageToken !== undefined && typeof answer.nextPageToken !== "string")
282
- ) {
283
- throw new KitRefusal("google_provider_response_invalid", "drive files");
284
- }
285
- files = answer.files.map(fileMetadata);
286
- nextPageToken = answer.nextPageToken ?? null;
287
- }
288
- const cache = new Map();
289
- const normalized = await normalizeFiles(options, files, dependencies, cache);
290
- const knownFolders = new Set([...cursor.folders, ...cursor.visited]);
291
- const discoveredFolders = [
292
- ...new Set(
293
- files
294
- .filter((file) => file.mimeType === FOLDER_MIME && !knownFolders.has(file.id))
295
- .map((file) => file.id),
296
- ),
297
- ];
298
- if (knownFolders.size + discoveredFolders.length > FOLDER_FRONTIER_MAX) {
299
- throw new KitRefusal("google_drive_selection_exceeded");
207
+ function snapshotQuery(backfillFloor) {
208
+ const clauses = ["'me' in owners", "trashed = false", `mimeType != '${FOLDER_MIME}'`];
209
+ if (backfillFloor !== null && backfillFloor !== undefined) {
210
+ clauses.push(`modifiedTime >= '${backfillFloor}'`);
300
211
  }
301
- let next;
302
- if (nextPageToken !== null) {
303
- next = {
304
- phase: "snapshot",
305
- selection: cursor.selection,
306
- pageToken: nextPageToken,
307
- changeToken: cursor.changeToken,
308
- folders: [...cursor.folders, ...discoveredFolders],
309
- visited: cursor.visited,
310
- };
311
- } else if (
312
- selected.kind === "folder" &&
313
- cursor.folders.length - 1 + discoveredFolders.length > 0
212
+ return clauses.join(" and ");
213
+ }
214
+
215
+ async function snapshotPage(options, cursor, dependencies) {
216
+ const url = new URL("/drive/v3/files", DRIVE_ORIGIN);
217
+ url.searchParams.set("q", snapshotQuery(options.backfillFloor));
218
+ url.searchParams.set("pageSize", `${options.limit}`);
219
+ url.searchParams.set("fields", `nextPageToken,files(${FILE_FIELDS})`);
220
+ if (cursor.pageToken !== null) url.searchParams.set("pageToken", cursor.pageToken);
221
+ const answer = await googleJson({ ...common(options), url: url.toString() }, dependencies);
222
+ if (
223
+ !Array.isArray(answer.files) ||
224
+ (answer.nextPageToken !== undefined && typeof answer.nextPageToken !== "string")
314
225
  ) {
315
- next = {
316
- phase: "snapshot",
317
- selection: cursor.selection,
318
- pageToken: null,
319
- changeToken: cursor.changeToken,
320
- folders: [...cursor.folders.slice(1), ...discoveredFolders],
321
- visited: [...cursor.visited, cursor.folders[0]],
322
- };
323
- } else if (cursor.selection + 1 < selections.length) {
324
- const following = selections[cursor.selection + 1];
325
- next = {
326
- phase: "snapshot",
327
- selection: cursor.selection + 1,
328
- pageToken: null,
329
- changeToken: cursor.changeToken,
330
- folders: following.kind === "folder" ? [following.id] : [],
331
- visited: [],
332
- };
333
- } else {
334
- next = { phase: "changes", selection: 0, pageToken: cursor.changeToken };
226
+ throw new KitRefusal("google_provider_response_invalid", "drive files");
335
227
  }
228
+ const normalized = await normalizeFiles(
229
+ options,
230
+ answer.files.map(fileMetadata),
231
+ dependencies,
232
+ new Map(),
233
+ );
234
+ const next =
235
+ answer.nextPageToken === undefined
236
+ ? { phase: "changes", pageToken: cursor.changeToken }
237
+ : { phase: "snapshot", pageToken: answer.nextPageToken, changeToken: cursor.changeToken };
336
238
  return {
337
239
  items: normalized.items,
338
- dropped: [...dropped, ...normalized.dropped],
240
+ dropped: normalized.dropped,
339
241
  cursor: JSON.stringify(next),
340
242
  more: true,
341
243
  };
342
244
  }
343
245
 
344
- async function selectedChange(options, file, dependencies, cache) {
345
- if (options.selection.files.includes(file.id)) return true;
346
- const folders = await parentPath(options, file, dependencies, cache);
347
- return folders.identifiers.some((folder) => options.selection.folders.includes(folder));
348
- }
349
-
350
246
  async function changesPage(options, cursor, dependencies) {
351
247
  const url = new URL("/drive/v3/changes", DRIVE_ORIGIN);
352
248
  url.searchParams.set("pageToken", cursor.pageToken);
@@ -370,66 +266,45 @@ async function changesPage(options, cursor, dependencies) {
370
266
  }
371
267
  const files = answer.changes
372
268
  .filter((change) => change?.removed !== true && change?.file !== undefined)
373
- .map((change) => fileMetadata(change.file));
374
- const cache = new Map();
375
- const selected = [];
376
- const dropped = [];
377
- for (const file of files) {
378
- if (file.mimeType === FOLDER_MIME) continue;
379
- try {
380
- if (await selectedChange(options, file, dependencies, cache)) selected.push(file);
381
- } catch (error) {
382
- if (!(error instanceof KitRefusal) || error.code !== "google_drive_item_missing") throw error;
383
- dropped.push(droppedFile(file, error.code));
384
- }
385
- }
386
- const normalized = await normalizeFiles(options, selected, dependencies, cache);
269
+ .map((change) => fileMetadata(change.file))
270
+ .filter((file) => file.ownedByMe === true && file.trashed !== true);
271
+ const normalized = await normalizeFiles(options, files, dependencies, new Map());
387
272
  const pageToken = answer.nextPageToken ?? answer.newStartPageToken;
388
273
  if (typeof pageToken !== "string") {
389
274
  throw new KitRefusal("google_provider_response_invalid", "drive change token");
390
275
  }
391
276
  return {
392
277
  items: normalized.items,
393
- dropped: [...dropped, ...normalized.dropped],
394
- cursor: JSON.stringify({ phase: "changes", selection: 0, pageToken }),
278
+ dropped: normalized.dropped,
279
+ cursor: JSON.stringify({ phase: "changes", pageToken }),
395
280
  more: answer.nextPageToken !== undefined,
396
281
  };
397
282
  }
398
283
 
399
- export async function drivePage(options, dependencies = {}) {
400
- const selections = selectionsOf(options.selection);
401
- const stored = cursorOf(options.cursor, selections);
402
- const cursor =
403
- stored ?? {
404
- phase: "snapshot",
405
- selection: 0,
406
- pageToken: null,
407
- changeToken: await startPageToken(options, dependencies),
408
- folders: selections[0].kind === "folder" ? [selections[0].id] : [],
409
- visited: [],
410
- };
411
- if (cursor.phase === "snapshot") {
412
- return snapshotPage(options, cursor, selections, dependencies);
284
+ async function openedSnapshot(options, dependencies) {
285
+ return { phase: "snapshot", pageToken: null, changeToken: await startPageToken(options, dependencies) };
286
+ }
287
+
288
+ export async function driveEmail(options, dependencies = {}) {
289
+ const about = await googleJson(
290
+ { ...common(options), url: `${DRIVE_ORIGIN}/drive/v3/about?fields=user(emailAddress)` },
291
+ dependencies,
292
+ );
293
+ if (typeof about.user?.emailAddress !== "string") {
294
+ throw new KitRefusal("google_provider_response_invalid", "drive about");
413
295
  }
296
+ return about.user.emailAddress;
297
+ }
298
+
299
+ export async function drivePage(options, dependencies = {}) {
300
+ const cursor = cursorOf(options.cursor) ?? (await openedSnapshot(options, dependencies));
301
+ if (cursor.phase === "snapshot") return snapshotPage(options, cursor, dependencies);
414
302
  try {
415
303
  return await changesPage(options, cursor, dependencies);
416
304
  } catch (error) {
417
305
  if (!(error instanceof KitRefusal) || error.code !== "google_drive_cursor_expired") {
418
306
  throw error;
419
307
  }
420
- const first = selections[0];
421
- return snapshotPage(
422
- options,
423
- {
424
- phase: "snapshot",
425
- selection: 0,
426
- pageToken: null,
427
- changeToken: await startPageToken(options, dependencies),
428
- folders: first.kind === "folder" ? [first.id] : [],
429
- visited: [],
430
- },
431
- selections,
432
- dependencies,
433
- );
308
+ return snapshotPage(options, await openedSnapshot(options, dependencies), dependencies);
434
309
  }
435
310
  }
@@ -0,0 +1,346 @@
1
+ import { SOURCE_CAPTURE_INTERVAL_MS } from "../contract.js";
2
+ import { desiredSources, maintainControl, openSession, reportSources } from "../house.js";
3
+ import { KitRefusal } from "../refusal.js";
4
+ import { signInModule } from "../setup.js";
5
+ import { captureEntry, localServiceState } from "../source.js";
6
+ import { noteNames, notePath, readHealth, readNote, removeNotes, writeNote } from "../state.js";
7
+ import { coalescedTask, residentWake } from "../tasks.js";
8
+ import {
9
+ authorizeGoogle,
10
+ forgetGoogleAccount,
11
+ googleAccount,
12
+ googleSignedIn,
13
+ GOOGLE_SCOPES,
14
+ GOOGLE_SOURCES,
15
+ } from "./oauth.js";
16
+ import { recordAccountEmail } from "./source.js";
17
+
18
+ const ENTRY_NOTES = ["checkpoints", "health", "gaps", "dropped", "originals", "entries"];
19
+
20
+ export const GOOGLE_APIS = Object.freeze({
21
+ gmail: "Gmail API",
22
+ calendar: "Google Calendar API",
23
+ drive: "Google Drive API",
24
+ });
25
+
26
+ async function heldEntries(directory) {
27
+ const entries = [];
28
+ for (const source of await noteNames(directory, GOOGLE_SOURCES, "entries")) {
29
+ entries.push(await readNote(notePath(directory, GOOGLE_SOURCES, source, "entries"), null));
30
+ }
31
+ return entries;
32
+ }
33
+
34
+ export async function accountEntries(directory, account) {
35
+ return (await heldEntries(directory)).filter((entry) => entry.account === account);
36
+ }
37
+
38
+ async function entryReport(directory, entry) {
39
+ const account = await googleAccount(directory, entry.account);
40
+ const email = account?.email ?? null;
41
+ if (account?.scopes.includes(GOOGLE_SCOPES[entry.service]) !== true) {
42
+ return { source: entry.source, state: "signed_out", email };
43
+ }
44
+ const health = await readHealth(directory, GOOGLE_SOURCES, entry.source);
45
+ if (health?.state === "failed") {
46
+ return { source: entry.source, state: "failed", email, cause: health.cause ?? null };
47
+ }
48
+ return { source: entry.source, state: "ready", email };
49
+ }
50
+
51
+ async function reportEntries(context, entries) {
52
+ if (entries.length === 0) return;
53
+ const sources = [];
54
+ for (const entry of entries) sources.push(await entryReport(context.directory, entry));
55
+ await reportSources(context.session, sources, context.signal);
56
+ }
57
+
58
+ async function captureOccurrence(context, entry) {
59
+ const delivered = await captureEntry(context, entry);
60
+ await reportEntries(context, [entry]);
61
+ return delivered;
62
+ }
63
+
64
+ async function collectedClient(entry, present, signal) {
65
+ const collected = await present(
66
+ {
67
+ steps: [
68
+ {
69
+ kind: "collect",
70
+ label: "Client ID of your Google OAuth client",
71
+ name: "client_id",
72
+ description: `In your own Google Cloud project, enable the ${GOOGLE_APIS[entry.service]} and create an OAuth client of type Desktop app. Paste its client ID here.`,
73
+ },
74
+ {
75
+ kind: "collect",
76
+ label: "Client secret of that OAuth client",
77
+ name: "client_secret",
78
+ description: `Paste the same client's secret. It stays on this Environment beside the ${entry.account} account's sign-in.`,
79
+ },
80
+ ],
81
+ },
82
+ signal,
83
+ );
84
+ return { id: collected.client_id.trim(), secret: collected.client_secret.trim() };
85
+ }
86
+
87
+ function consent(entry, present, signal) {
88
+ return async ({ url }) => {
89
+ const collected = await present(
90
+ {
91
+ steps: [
92
+ {
93
+ kind: "visit",
94
+ label: `Sign in to Google for ${entry.account} and allow access for ${entry.service}`,
95
+ url,
96
+ },
97
+ {
98
+ kind: "collect",
99
+ label: "The address your browser ended on",
100
+ name: "address",
101
+ description:
102
+ "After you allow access, Google sends your browser to a 127.0.0.1 address that does not open. Copy that whole address from the address bar and paste it here.",
103
+ },
104
+ ],
105
+ },
106
+ signal,
107
+ );
108
+ return collected.address;
109
+ };
110
+ }
111
+
112
+ async function guidedSignIn(context, entry, present, signal) {
113
+ try {
114
+ const account = await googleAccount(context.directory, entry.account);
115
+ await authorizeGoogle(
116
+ {
117
+ directory: context.directory,
118
+ account: entry.account,
119
+ client: account?.client ?? (await collectedClient(entry, present, signal)),
120
+ scopes: [GOOGLE_SCOPES[entry.service]],
121
+ approve: consent(entry, present, signal),
122
+ },
123
+ context.dependencies,
124
+ );
125
+ return "ready";
126
+ } catch (error) {
127
+ if (signal.aborted) return "cancelled";
128
+ if (error instanceof KitRefusal && error.code === "google_authorization_declined") {
129
+ return "cancelled";
130
+ }
131
+ return "failed";
132
+ }
133
+ }
134
+
135
+ async function signIn(context, entry) {
136
+ const outcome = await signInModule(context, {
137
+ category: "connector",
138
+ name: entry.source,
139
+ login: { integration: "google", instance: entry.account, service: entry.service },
140
+ install: null,
141
+ signedIn: () => googleSignedIn(context.directory, entry.account, entry.service),
142
+ guided: ({ present, signal }) => guidedSignIn(context, entry, present, signal),
143
+ });
144
+ if (outcome.status !== "ready") return false;
145
+ await removeNotes(context.directory, GOOGLE_SOURCES, entry.source, ["health"]);
146
+ try {
147
+ await recordAccountEmail(context, { account: entry.account, service: entry.service });
148
+ } catch (error) {
149
+ if (!(error instanceof KitRefusal) || error.code === "kit_command_cancelled") throw error;
150
+ }
151
+ await reportEntries(context, [entry]);
152
+ return true;
153
+ }
154
+
155
+ export function sourceEntries(context) {
156
+ const { directory, session, dependencies } = context;
157
+ const interval = dependencies.sourceCaptureIntervalMs ?? SOURCE_CAPTURE_INTERVAL_MS;
158
+ const held = new Map();
159
+ const wake = residentWake();
160
+ let attempted = new Set();
161
+ let signingIn = null;
162
+ let cadence = Promise.resolve();
163
+
164
+ function occasion(record) {
165
+ return {
166
+ session,
167
+ directory,
168
+ dependencies,
169
+ now: context.now,
170
+ signal: AbortSignal.any([context.signal, record.stop.signal]),
171
+ };
172
+ }
173
+
174
+ function occur(record) {
175
+ const current = (record.running ?? Promise.resolve())
176
+ .catch(() => {})
177
+ .then(() => {
178
+ record.last = context.now().getTime();
179
+ return captureOccurrence(occasion(record), record.entry);
180
+ });
181
+ record.running = current;
182
+ current
183
+ .finally(() => {
184
+ if (record.running === current) record.running = null;
185
+ signIns.trigger();
186
+ wake.notify();
187
+ })
188
+ .catch(() => {});
189
+ return current;
190
+ }
191
+
192
+ function track(entry) {
193
+ const record = held.get(entry.source);
194
+ if (record !== undefined) record.entry = entry;
195
+ else held.set(entry.source, { entry, last: null, running: null, stop: new AbortController() });
196
+ }
197
+
198
+ async function drop(source, record) {
199
+ record.stop.abort();
200
+ held.delete(source);
201
+ if (signingIn?.source === source) await signingIn.task.catch(() => {});
202
+ await record.running?.catch(() => {});
203
+ await removeNotes(directory, GOOGLE_SOURCES, source, ENTRY_NOTES);
204
+ }
205
+
206
+ const signIns = coalescedTask(context.signal, context.unavailable, async () => {
207
+ for (const record of [...held.values()]) {
208
+ if (context.draining()) return;
209
+ const { entry } = record;
210
+ if (record.stop.signal.aborted || attempted.has(entry.source)) continue;
211
+ if (await googleSignedIn(directory, entry.account, entry.service)) continue;
212
+ attempted.add(entry.source);
213
+ const task = signIn(occasion(record), entry);
214
+ signingIn = { source: entry.source, task };
215
+ try {
216
+ if (await task) record.last = null;
217
+ } catch (error) {
218
+ if (!record.stop.signal.aborted) throw error;
219
+ } finally {
220
+ signingIn = null;
221
+ }
222
+ wake.notify();
223
+ }
224
+ });
225
+
226
+ const reconciling = coalescedTask(context.signal, context.unavailable, async () => {
227
+ const desired = await desiredSources(session, context.signal);
228
+ const named = new Set(desired.map((entry) => entry.source));
229
+ for (const [source, record] of [...held]) {
230
+ if (!named.has(source)) await drop(source, record);
231
+ }
232
+ for (const entry of desired) {
233
+ await writeNote(notePath(directory, GOOGLE_SOURCES, entry.source, "entries"), entry);
234
+ track(entry);
235
+ }
236
+ await reportEntries({ session, directory, signal: context.signal }, desired);
237
+ attempted = new Set();
238
+ signIns.trigger();
239
+ wake.notify();
240
+ const kept = [...new Set(desired.map((entry) => entry.account))];
241
+ for (const account of await noteNames(directory, GOOGLE_SOURCES, "credentials")) {
242
+ if (!kept.includes(account)) await forgetGoogleAccount(directory, account, kept, dependencies);
243
+ }
244
+ });
245
+
246
+ async function startDue() {
247
+ let next = interval;
248
+ const now = context.now().getTime();
249
+ for (const record of [...held.values()]) {
250
+ if (record.running !== null) continue;
251
+ const due = record.last === null ? 0 : record.last + interval - now;
252
+ if (due > 0) {
253
+ next = Math.min(next, due);
254
+ continue;
255
+ }
256
+ try {
257
+ if (!(await googleSignedIn(directory, record.entry.account, record.entry.service))) continue;
258
+ } catch (error) {
259
+ context.unavailable(error);
260
+ continue;
261
+ }
262
+ if (record.stop.signal.aborted || record.running !== null) continue;
263
+ occur(record).catch((error) => {
264
+ if (!record.stop.signal.aborted && !context.signal.aborted) context.unavailable(error);
265
+ });
266
+ }
267
+ return next;
268
+ }
269
+
270
+ return {
271
+ async start() {
272
+ try {
273
+ for (const entry of await heldEntries(directory)) track(entry);
274
+ } catch (error) {
275
+ context.unavailable(error);
276
+ }
277
+ cadence = (async () => {
278
+ while (!context.signal.aborted) {
279
+ await wake.wait(context.draining() ? interval : await startDue(), context.signal);
280
+ }
281
+ })();
282
+ },
283
+ reconcile: () => reconciling.trigger(),
284
+ capture(source) {
285
+ const record = held.get(source);
286
+ if (record === undefined) return Promise.reject(new KitRefusal("kit_instance_unknown", source));
287
+ return occur(record);
288
+ },
289
+ idle: () =>
290
+ reconciling.idle() &&
291
+ signIns.idle() &&
292
+ [...held.values()].every((record) => record.running === null),
293
+ async settled() {
294
+ await reconciling.settled();
295
+ await signIns.settled();
296
+ await cadence;
297
+ await Promise.all([...held.values()].map((record) => record.running?.catch(() => {})));
298
+ },
299
+ };
300
+ }
301
+
302
+ function namedEntries(entries, service) {
303
+ if (service === null) return entries;
304
+ const named = entries.filter((entry) => entry.service === service);
305
+ if (named.length === 0) throw new KitRefusal("kit_service_unknown", service);
306
+ return named;
307
+ }
308
+
309
+ export async function captureAccount(options, dependencies = {}) {
310
+ const entries = namedEntries(options.entries, options.service);
311
+ const session = await openSession(options, dependencies);
312
+ return maintainControl(
313
+ session,
314
+ async (signal) => {
315
+ const services = [];
316
+ for (const entry of entries) {
317
+ services.push(
318
+ session.resident === undefined
319
+ ? await captureOccurrence(
320
+ { session, directory: options.directory, dependencies, signal, now: session.now },
321
+ entry,
322
+ )
323
+ : await session.capture(entry.source, signal),
324
+ );
325
+ }
326
+ return { instance: options.account, services };
327
+ },
328
+ options.signal,
329
+ );
330
+ }
331
+
332
+ export async function accountStatus(options) {
333
+ const services = [];
334
+ for (const entry of namedEntries(options.entries, options.service)) {
335
+ const local = await localServiceState(options.directory, GOOGLE_SOURCES, entry.source);
336
+ services.push({
337
+ service: entry.service,
338
+ source_ref: entry.source,
339
+ rooms: entry.rooms,
340
+ signed_in: await googleSignedIn(options.directory, entry.account, entry.service),
341
+ ...local,
342
+ enable: local.health.cause === "api_disabled" ? GOOGLE_APIS[entry.service] : null,
343
+ });
344
+ }
345
+ return { instance: options.account, preset: "google", services };
346
+ }