@agentshouse/kit 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +722 -263
- package/bin/connect-linux.sh +200 -25
- package/bin/kit.js +179 -157
- package/bootstrap.json +17 -0
- package/package.json +7 -2
- package/src/adapter-mcp.js +76 -12
- package/src/agents.js +63 -8
- package/src/binding.js +52 -0
- package/src/blocks.js +2 -26
- package/src/bridge.js +28 -14
- package/src/bytes.js +19 -8
- package/src/configuration.js +13 -209
- package/src/connector-login.js +3 -84
- package/src/contract.js +36 -11
- package/src/delivery.js +4 -7
- package/src/directory.js +6 -26
- package/src/draft.js +4 -82
- package/src/git-main.js +138 -0
- package/src/git.js +62 -0
- package/src/google/calendar.js +39 -45
- package/src/google/drive.js +61 -186
- package/src/google/entries.js +346 -0
- package/src/google/gateway.js +3 -6
- package/src/google/gmail.js +33 -23
- package/src/google/oauth.js +106 -66
- package/src/google/source.js +21 -23
- package/src/host.js +6 -2
- package/src/house-main.js +65 -8
- package/src/house.js +446 -127
- package/src/how-we-work.md +25 -0
- package/src/inbound.js +2 -18
- package/src/kit-work-stream.js +118 -0
- package/src/launcher.js +46 -10
- package/src/local-files.js +3 -11
- package/src/login.js +23 -11
- package/src/mcp.js +18 -0
- package/src/mount.js +13 -21
- package/src/original.js +4 -4
- package/src/pacing.js +41 -0
- package/src/plan.js +37 -45
- package/src/repository.js +3 -61
- package/src/session-bin/git +2 -0
- package/src/setup.js +14 -15
- package/src/skill-set/ask-matt/PHASE-BOUNDARIES.md +55 -0
- package/src/skill-set/ask-matt/SKILL.md +91 -0
- package/src/skill-set/ask-matt/agents/openai.yaml +4 -0
- package/src/skill-set/code-review/LICENSE +21 -0
- package/src/skill-set/code-review/SKILL.md +89 -0
- package/src/skill-set/code-review/agents/openai.yaml +3 -0
- package/src/skill-set/codebase-design/DEEPENING.md +37 -0
- package/src/skill-set/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/src/skill-set/codebase-design/LICENSE +21 -0
- package/src/skill-set/codebase-design/SKILL.md +116 -0
- package/src/skill-set/codebase-design/agents/openai.yaml +3 -0
- package/src/skill-set/diagnosing-bugs/LICENSE +21 -0
- package/src/skill-set/diagnosing-bugs/SKILL.md +140 -0
- package/src/skill-set/diagnosing-bugs/agents/openai.yaml +3 -0
- package/src/skill-set/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
- package/src/skill-set/domain-modeling/ADR-FORMAT.md +47 -0
- package/src/skill-set/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/src/skill-set/domain-modeling/LICENSE +21 -0
- package/src/skill-set/domain-modeling/SKILL.md +76 -0
- package/src/skill-set/domain-modeling/agents/openai.yaml +3 -0
- package/src/skill-set/grill-me/LICENSE +21 -0
- package/src/skill-set/grill-me/SKILL.md +8 -0
- package/src/skill-set/grill-me/agents/openai.yaml +4 -0
- package/src/skill-set/grill-with-docs/LICENSE +21 -0
- package/src/skill-set/grill-with-docs/SKILL.md +8 -0
- package/src/skill-set/grill-with-docs/agents/openai.yaml +4 -0
- package/src/skill-set/grilling/LICENSE +21 -0
- package/src/{starter-set → skill-set}/grilling/SKILL.md +1 -1
- package/src/skill-set/handoff/LICENSE +21 -0
- package/src/skill-set/handoff/SKILL.md +17 -0
- package/src/skill-set/handoff/agents/openai.yaml +4 -0
- package/src/skill-set/implement/LICENSE +21 -0
- package/src/skill-set/implement/SKILL.md +16 -0
- package/src/skill-set/implement/agents/openai.yaml +4 -0
- package/src/skill-set/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/src/skill-set/improve-codebase-architecture/LICENSE +21 -0
- package/src/skill-set/improve-codebase-architecture/SKILL.md +72 -0
- package/src/skill-set/improve-codebase-architecture/agents/openai.yaml +4 -0
- package/src/skill-set/prototype/LICENSE +21 -0
- package/src/skill-set/prototype/LOGIC.md +67 -0
- package/src/skill-set/prototype/SKILL.md +28 -0
- package/src/skill-set/prototype/UI.md +112 -0
- package/src/skill-set/prototype/agents/openai.yaml +3 -0
- package/src/skill-set/research/LICENSE +21 -0
- package/src/skill-set/research/SKILL.md +14 -0
- package/src/skill-set/research/agents/openai.yaml +3 -0
- package/src/skill-set/resolving-merge-conflicts/LICENSE +21 -0
- package/src/skill-set/resolving-merge-conflicts/SKILL.md +16 -0
- package/src/skill-set/resolving-merge-conflicts/agents/openai.yaml +3 -0
- package/src/skill-set/tdd/LICENSE +21 -0
- package/src/skill-set/tdd/SKILL.md +40 -0
- package/src/skill-set/tdd/agents/openai.yaml +3 -0
- package/src/skill-set/tdd/mocking.md +59 -0
- package/src/skill-set/tdd/tests.md +77 -0
- package/src/skill-set/teach/GLOSSARY-FORMAT.md +35 -0
- package/src/skill-set/teach/LEARNING-RECORD-FORMAT.md +46 -0
- package/src/skill-set/teach/LICENSE +21 -0
- package/src/skill-set/teach/MISSION-FORMAT.md +31 -0
- package/src/skill-set/teach/RESOURCES-FORMAT.md +32 -0
- package/src/skill-set/teach/SKILL.md +141 -0
- package/src/skill-set/teach/agents/openai.yaml +4 -0
- package/src/skill-set/to-questionnaire/LICENSE +21 -0
- package/src/skill-set/to-questionnaire/SKILL.md +55 -0
- package/src/skill-set/to-questionnaire/agents/openai.yaml +4 -0
- package/src/skill-set/to-spec/LICENSE +21 -0
- package/src/skill-set/to-spec/SKILL.md +76 -0
- package/src/skill-set/to-spec/agents/openai.yaml +4 -0
- package/src/skill-set/to-tickets/LICENSE +21 -0
- package/src/skill-set/to-tickets/SKILL.md +106 -0
- package/src/skill-set/to-tickets/agents/openai.yaml +4 -0
- package/src/skill-set/wait-what/LICENSE +21 -0
- package/src/skill-set/wait-what/SKILL.md +8 -0
- package/src/skill-set/wait-what/agents/openai.yaml +4 -0
- package/src/skill-set/wayfinder/LICENSE +21 -0
- package/src/skill-set/wayfinder/SKILL.md +129 -0
- package/src/skill-set/wayfinder/agents/openai.yaml +4 -0
- package/src/skill-set/writing-for-agents/LICENSE +21 -0
- package/src/skill-set/writing-for-agents/SKILL-MECHANICS.md +22 -0
- package/src/skill-set/writing-for-agents/SKILL.md +83 -0
- package/src/skill-set/writing-for-agents/agents/openai.yaml +3 -0
- package/src/skill-set.js +85 -0
- package/src/source.js +93 -54
- package/src/state.js +19 -1
- package/src/sync-library.js +7 -11
- package/src/sync-state.js +16 -3
- package/src/sync.js +129 -41
- package/src/tasks.js +75 -0
- package/src/telegram-view.js +214 -0
- package/src/telegram.js +161 -66
- package/src/turn-frames.js +69 -0
- package/src/worker.js +685 -184
- package/src/working-copy.js +1221 -0
- package/src/workspace.js +40 -32
- package/src/google/declared.js +0 -146
- package/src/google/id.js +0 -1
- package/src/instance/google.js +0 -1
- package/src/starter-set.js +0 -17
- /package/src/{starter-set/grilling → skill-set/ask-matt}/LICENSE +0 -0
- /package/src/{starter-set → skill-set}/grilling/agents/openai.yaml +0 -0
package/src/google/drive.js
CHANGED
|
@@ -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 =
|
|
24
|
-
|
|
22
|
+
const FILE_FIELDS =
|
|
23
|
+
"id,name,mimeType,modifiedTime,version,parents,webViewLink,owners,ownedByMe,trashed";
|
|
25
24
|
|
|
26
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
375
|
-
const
|
|
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:
|
|
394
|
-
cursor: JSON.stringify({ phase: "changes",
|
|
278
|
+
dropped: normalized.dropped,
|
|
279
|
+
cursor: JSON.stringify({ phase: "changes", pageToken }),
|
|
395
280
|
more: answer.nextPageToken !== undefined,
|
|
396
281
|
};
|
|
397
282
|
}
|
|
398
283
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
+
}
|