@agentshouse/kit 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +697 -260
- 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 +36 -21
- 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 +674 -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/skill-set.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { cp, lstat, mkdir, readdir, realpath, rm, symlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { KitRefusal } from "./refusal.js";
|
|
6
|
+
import { readNote, writeNote, writeText } from "./state.js";
|
|
7
|
+
|
|
8
|
+
export const SKILL_SET_SOURCE = fileURLToPath(new URL("skill-set/", import.meta.url));
|
|
9
|
+
const KIT_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
10
|
+
|
|
11
|
+
function recordPath(directory) {
|
|
12
|
+
return join(directory, "state", "skill-set.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function howWeWorkNotePath(directory) {
|
|
16
|
+
return join(directory, "state", "how-we-work");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function placed(home, name) {
|
|
20
|
+
return {
|
|
21
|
+
skill: join(home, ".agents", "skills", name),
|
|
22
|
+
link: join(home, ".claude", "skills", name),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function present(path) {
|
|
27
|
+
try {
|
|
28
|
+
await lstat(path);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === "ENOENT") return false;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function removeRecorded(home, names) {
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
const { skill, link } = placed(home, name);
|
|
39
|
+
await rm(link, { recursive: true, force: true });
|
|
40
|
+
await rm(skill, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function installed(directory, home, version) {
|
|
45
|
+
const names = [];
|
|
46
|
+
for (const name of (await readdir(SKILL_SET_SOURCE)).sort()) {
|
|
47
|
+
const { skill, link } = placed(home, name);
|
|
48
|
+
if (!(await present(skill)) && !(await present(link))) names.push(name);
|
|
49
|
+
}
|
|
50
|
+
await writeNote(recordPath(directory), { names });
|
|
51
|
+
await mkdir(join(home, ".agents", "skills"), { recursive: true });
|
|
52
|
+
await mkdir(join(home, ".claude", "skills"), { recursive: true });
|
|
53
|
+
const shared =
|
|
54
|
+
(await realpath(join(home, ".claude", "skills"))) === (await realpath(join(home, ".agents", "skills")));
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const { skill, link } = placed(home, name);
|
|
57
|
+
await cp(join(SKILL_SET_SOURCE, name), skill, { recursive: true });
|
|
58
|
+
if (!shared) await symlink(join("..", "..", ".agents", "skills", name), link);
|
|
59
|
+
}
|
|
60
|
+
await writeNote(recordPath(directory), { version, names });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function reconcileSkillSet({ directory, home, accepted, version = KIT_VERSION }) {
|
|
64
|
+
try {
|
|
65
|
+
const record = await readNote(recordPath(directory), null);
|
|
66
|
+
if (accepted && record?.version === version) return;
|
|
67
|
+
if (record !== null) await removeRecorded(home, record.names);
|
|
68
|
+
if (accepted) await installed(directory, home, version);
|
|
69
|
+
else await rm(recordPath(directory), { force: true });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof KitRefusal) throw error;
|
|
72
|
+
throw new KitRefusal("kit_skill_set_unavailable", `${error.path ?? home} (${error.code ?? "unknown"})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function howWeWorkOwed(directory) {
|
|
77
|
+
return (
|
|
78
|
+
(await readNote(recordPath(directory), null)) !== null &&
|
|
79
|
+
!(await present(howWeWorkNotePath(directory)))
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function noteHowWeWork(directory) {
|
|
84
|
+
await writeText(howWeWorkNotePath(directory), "");
|
|
85
|
+
}
|
package/src/source.js
CHANGED
|
@@ -12,8 +12,9 @@ import {
|
|
|
12
12
|
newCheckpoint,
|
|
13
13
|
stallOf,
|
|
14
14
|
} from "./delivery.js";
|
|
15
|
-
import { captureBatch, openSession } from "./house.js";
|
|
16
|
-
import {
|
|
15
|
+
import { captureBatch, maintainControl, openSession } from "./house.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 (
|
|
36
|
+
if (service.google !== undefined) {
|
|
34
37
|
return googlePage(
|
|
35
38
|
{
|
|
36
39
|
directory: context.directory,
|
|
37
|
-
|
|
38
|
-
|
|
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,25 +200,70 @@ 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
|
-
|
|
208
|
-
instance,
|
|
229
|
+
return maintainControl(
|
|
209
230
|
session,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
async (signal) => {
|
|
232
|
+
const context = {
|
|
233
|
+
instance,
|
|
234
|
+
session,
|
|
235
|
+
directory: options.directory,
|
|
236
|
+
signal,
|
|
237
|
+
now: session.now,
|
|
238
|
+
dependencies,
|
|
239
|
+
};
|
|
240
|
+
const delivered = [];
|
|
241
|
+
for (const service of services) delivered.push(await deliverService(context, service));
|
|
242
|
+
return { instance: instance.id, services: delivered };
|
|
243
|
+
},
|
|
244
|
+
options.signal,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
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
|
+
})),
|
|
215
266
|
};
|
|
216
|
-
const delivered = [];
|
|
217
|
-
for (const service of services) delivered.push(await deliverService(context, service));
|
|
218
|
-
return { instance: instance.id, services: delivered };
|
|
219
267
|
}
|
|
220
268
|
|
|
221
269
|
export async function sourceStatus(options) {
|
|
@@ -228,24 +276,7 @@ export async function sourceStatus(options) {
|
|
|
228
276
|
service: service.service,
|
|
229
277
|
source_ref: service.sourceRef,
|
|
230
278
|
rooms: service.rooms,
|
|
231
|
-
|
|
232
|
-
state: "pending",
|
|
233
|
-
category: null,
|
|
234
|
-
observed_at: null,
|
|
235
|
-
},
|
|
236
|
-
checkpoint: await readCheckpoint(options.directory, instance.id, service.service),
|
|
237
|
-
gaps: await readCoverageGaps(options.directory, instance.id, service.service),
|
|
238
|
-
dropped: await readDroppedItems(options.directory, instance.id, service.service),
|
|
239
|
-
originals: (await readSourceOriginals(options.directory, instance.id, service.service)).map(
|
|
240
|
-
(reference) => ({
|
|
241
|
-
room: reference.room,
|
|
242
|
-
record: reference.record,
|
|
243
|
-
position: reference.position,
|
|
244
|
-
native_object_id: reference.native_object_id,
|
|
245
|
-
save: reference.save,
|
|
246
|
-
error: reference.error,
|
|
247
|
-
}),
|
|
248
|
-
),
|
|
279
|
+
...(await localServiceState(options.directory, instance.id, service.service)),
|
|
249
280
|
});
|
|
250
281
|
}
|
|
251
282
|
return { instance: instance.id, preset: instance.preset, services: reported };
|
|
@@ -255,32 +286,40 @@ export async function retrySourceOriginals(options, dependencies = {}) {
|
|
|
255
286
|
const configuration = await readConfiguration(options.configuration);
|
|
256
287
|
const instance = sourceInstance(configuration, options.instance);
|
|
257
288
|
const services = declaredServices(instance, options.service ?? null);
|
|
258
|
-
const context = { instance, directory: options.directory, signal: options.signal };
|
|
259
289
|
const outstanding = [];
|
|
260
290
|
for (const service of services) {
|
|
261
291
|
outstanding.push((await readSourceOriginals(options.directory, instance.id, service.service)).length);
|
|
262
292
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
293
|
+
const retry = async (context) => {
|
|
294
|
+
const retried = [];
|
|
295
|
+
for (const [position, service] of services.entries()) {
|
|
296
|
+
retried.push({
|
|
297
|
+
service: service.service,
|
|
298
|
+
originals:
|
|
299
|
+
outstanding[position] === 0
|
|
300
|
+
? []
|
|
301
|
+
: await saveSourceOriginals(context, serviceBinding(context, service)),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return { instance: instance.id, services: retried };
|
|
305
|
+
};
|
|
306
|
+
const context = { instance, directory: options.directory, signal: options.signal };
|
|
307
|
+
if (outstanding.every((count) => count === 0)) return retry(context);
|
|
308
|
+
const session = await openSession(options, dependencies);
|
|
309
|
+
return maintainControl(session, (signal) => retry({ ...context, session, signal }), options.signal);
|
|
275
310
|
}
|
|
276
311
|
|
|
277
312
|
export async function resetSource(options, dependencies = {}) {
|
|
278
313
|
const configuration = await readConfiguration(options.configuration);
|
|
279
314
|
const instance = sourceInstance(configuration, options.instance);
|
|
280
315
|
declaredServices(instance, options.service);
|
|
281
|
-
await openSession(options, dependencies);
|
|
282
|
-
return
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
316
|
+
const session = await openSession(options, dependencies);
|
|
317
|
+
return maintainControl(
|
|
318
|
+
session,
|
|
319
|
+
async () => ({
|
|
320
|
+
instance: instance.id,
|
|
321
|
+
services: await resetCheckpoints(options.directory, instance.id, options.service),
|
|
322
|
+
}),
|
|
323
|
+
options.signal,
|
|
324
|
+
);
|
|
286
325
|
}
|
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/sync-library.js
CHANGED
|
@@ -100,13 +100,12 @@ function recordedMap(authority) {
|
|
|
100
100
|
return new Map(authority.files.map((file) => [file.path, file]));
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function structuredOrReplace(root, relative, recorded, content) {
|
|
103
|
+
export function structuredOrReplace(root, relative, recorded, content) {
|
|
104
104
|
const path = housePath(root, relative);
|
|
105
|
+
const whole = [{ op: "replace", path, base: recorded.revision, content }];
|
|
105
106
|
const before = parse(recorded.content, relative);
|
|
106
107
|
const after = parse(content, relative);
|
|
107
|
-
if (!before.ok || !after.ok)
|
|
108
|
-
return [{ op: "replace", path, base: recorded.revision, content }];
|
|
109
|
-
}
|
|
108
|
+
if (!before.ok || !after.ok) return whole;
|
|
110
109
|
const ops = [];
|
|
111
110
|
if (before.document.preamble !== after.document.preamble) {
|
|
112
111
|
ops.push({
|
|
@@ -124,9 +123,7 @@ function structuredOrReplace(root, relative, recorded, content) {
|
|
|
124
123
|
if (JSON.stringify(before.document.fields[name]) === JSON.stringify(after.document.fields[name])) {
|
|
125
124
|
continue;
|
|
126
125
|
}
|
|
127
|
-
if (after.document.fields[name] === undefined)
|
|
128
|
-
return [{ op: "replace", path, base: recorded.revision, content }];
|
|
129
|
-
}
|
|
126
|
+
if (after.document.fields[name] === undefined) return whole;
|
|
130
127
|
const change = { op: "set_field", path, field: name, value: after.document.fields[name] };
|
|
131
128
|
if (before.document.fields[name] !== undefined) change.base = before.document.fields[name];
|
|
132
129
|
ops.push(change);
|
|
@@ -139,12 +136,11 @@ function structuredOrReplace(root, relative, recorded, content) {
|
|
|
139
136
|
const prior = before.document.sections[name];
|
|
140
137
|
const next = after.document.sections[name];
|
|
141
138
|
if (prior === next) continue;
|
|
142
|
-
if (prior === undefined || next === undefined)
|
|
143
|
-
return [{ op: "replace", path, base: recorded.revision, content }];
|
|
144
|
-
}
|
|
139
|
+
if (prior === undefined || next === undefined) return whole;
|
|
145
140
|
ops.push({ op: "replace_section", path, section: name, base: prior, content: next });
|
|
146
141
|
}
|
|
147
|
-
|
|
142
|
+
if (ops.length === 0) return whole;
|
|
143
|
+
return applyChanges({ [relative]: recorded.content }, ops, root)[relative] === content ? ops : whole;
|
|
148
144
|
}
|
|
149
145
|
|
|
150
146
|
export function deriveChanges(state, authority, localFiles) {
|
package/src/sync-state.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, readdir, realpath, rm } from "node:fs/promises";
|
|
3
|
-
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { mkdir, readFile, readdir, realpath, rm, rmdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { EXPLICIT_SYNC_VERSION } from "./contract.js";
|
|
5
5
|
import { KitRefusal } from "./refusal.js";
|
|
6
6
|
import { readNote, writeNote, writeText } from "./state.js";
|
|
@@ -26,6 +26,9 @@ function syncFile(directory, key, kind = "") {
|
|
|
26
26
|
function recordedAuthority(authority) {
|
|
27
27
|
const operations = authority?.operations;
|
|
28
28
|
return (
|
|
29
|
+
authority?.kind === "room" &&
|
|
30
|
+
typeof authority.authority === "string" &&
|
|
31
|
+
typeof authority.path === "string" &&
|
|
29
32
|
typeof authority?.log_epoch === "string" &&
|
|
30
33
|
operations !== null &&
|
|
31
34
|
typeof operations === "object" &&
|
|
@@ -43,6 +46,7 @@ export async function readReplicaState(directory, replica) {
|
|
|
43
46
|
}
|
|
44
47
|
const state = await readNote(syncFile(directory, replicaKey(canonical)), null);
|
|
45
48
|
if (state === null) return null;
|
|
49
|
+
if (state.version !== EXPLICIT_SYNC_VERSION) return null;
|
|
46
50
|
if (!Array.isArray(state.authorities) || !state.authorities.every(recordedAuthority)) return null;
|
|
47
51
|
return state;
|
|
48
52
|
}
|
|
@@ -81,12 +85,21 @@ export async function writeReplicaFile(path, content, signal) {
|
|
|
81
85
|
return writeText(path, content, signal, 0o644);
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
export async function removeReplicaFile(
|
|
88
|
+
export async function removeReplicaFile(replica, rootPath, file) {
|
|
89
|
+
const root = locate(replica, rootPath);
|
|
90
|
+
const path = locate(replica, rootPath, file);
|
|
85
91
|
try {
|
|
86
92
|
await rm(path);
|
|
87
93
|
} catch (error) {
|
|
88
94
|
if (error.code !== "ENOENT") throw new KitRefusal("kit_state_unreadable", path);
|
|
89
95
|
}
|
|
96
|
+
for (let directory = dirname(path); directory.startsWith(`${root}/`); directory = dirname(directory)) {
|
|
97
|
+
try {
|
|
98
|
+
await rmdir(directory);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error.code !== "ENOENT") return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
export async function listFiles(root) {
|
package/src/sync.js
CHANGED
|
@@ -41,12 +41,12 @@ function sessionDependencies(options, dependencies) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
async function withSession(options, dependencies, operation) {
|
|
44
|
+
export async function withSession(options, dependencies, operation) {
|
|
45
45
|
const session = await openSession(options, sessionDependencies(options, dependencies));
|
|
46
46
|
return maintainControl(session, (signal) => operation(session, signal), options.signal);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
async function door(session, path, body, signal) {
|
|
49
|
+
export async function door(session, path, body, signal) {
|
|
50
50
|
return houseRequest(
|
|
51
51
|
session.house,
|
|
52
52
|
await heldControl(session, signal),
|
|
@@ -83,7 +83,7 @@ function changePosition(value, declaration) {
|
|
|
83
83
|
return { position: value.position, log_epoch: value.log_epoch };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function discoveryOf(value) {
|
|
86
|
+
export function discoveryOf(value) {
|
|
87
87
|
if (typeof value?.md_model_version !== "string" || !Array.isArray(value.authorities)) {
|
|
88
88
|
throw new KitRefusal("kit_sync_rejected", "discover");
|
|
89
89
|
}
|
|
@@ -93,7 +93,7 @@ function discoveryOf(value) {
|
|
|
93
93
|
return value;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
async function bootstrapBundle(session, prefixes, signal) {
|
|
96
|
+
export async function bootstrapBundle(session, prefixes, signal) {
|
|
97
97
|
const answer = await door(session, KIT_DOOR_BOOTSTRAP_PATH, { prefixes }, signal);
|
|
98
98
|
if (typeof answer?.transfer?.url === "string") {
|
|
99
99
|
const granted = await houseGetJson(answer.transfer.url, session.dependencies, signal);
|
|
@@ -105,15 +105,12 @@ async function bootstrapBundle(session, prefixes, signal) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function matchedDiscovery(discovery, scope) {
|
|
108
|
-
if (scope.manifest?.scope === "private") {
|
|
109
|
-
return discovery.authorities.find((entry) => entry.kind === "private");
|
|
110
|
-
}
|
|
111
108
|
return discovery.authorities.find(
|
|
112
109
|
(entry) => entry.kind === "room" && entry.room_ref === scope.manifest?.anchor,
|
|
113
110
|
);
|
|
114
111
|
}
|
|
115
112
|
|
|
116
|
-
|
|
113
|
+
function scopeContents(replica, scope) {
|
|
117
114
|
if (
|
|
118
115
|
typeof scope?.manifest?.path !== "string" ||
|
|
119
116
|
typeof scope.manifest.position !== "string" ||
|
|
@@ -124,11 +121,18 @@ async function writeScope(replica, scope, signal) {
|
|
|
124
121
|
if (!Array.isArray(scope.contents) || !Array.isArray(scope.manifest.files)) {
|
|
125
122
|
throw new KitRefusal("kit_bootstrap_rejected");
|
|
126
123
|
}
|
|
127
|
-
const files = [];
|
|
128
124
|
for (const entry of scope.contents) {
|
|
129
125
|
if (typeof entry?.path !== "string" || typeof entry.content !== "string") {
|
|
130
126
|
throw new KitRefusal("kit_bootstrap_rejected");
|
|
131
127
|
}
|
|
128
|
+
locate(replica, scope.manifest.path, entry.path);
|
|
129
|
+
}
|
|
130
|
+
return scope.contents;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function writeScope(replica, scope, signal) {
|
|
134
|
+
const files = [];
|
|
135
|
+
for (const entry of scopeContents(replica, scope)) {
|
|
132
136
|
const meta = scope.manifest.files.find((file) => file.path === entry.path);
|
|
133
137
|
await writeReplicaFile(locate(replica, scope.manifest.path, entry.path), entry.content, signal);
|
|
134
138
|
files.push({
|
|
@@ -142,18 +146,21 @@ async function writeScope(replica, scope, signal) {
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
async function replaceScope(replica, previous, scope, signal) {
|
|
145
|
-
const
|
|
146
|
-
const kept = new Set(next.map((file) => file.path));
|
|
149
|
+
const kept = new Set(scopeContents(replica, scope).map((entry) => entry.path));
|
|
147
150
|
const root = locate(replica, scope.manifest.path);
|
|
148
151
|
for (const path of await listFiles(root)) {
|
|
149
|
-
if (!kept.has(path)) await removeReplicaFile(
|
|
152
|
+
if (!kept.has(path)) await removeReplicaFile(replica, scope.manifest.path, path);
|
|
150
153
|
}
|
|
151
154
|
if (previous !== undefined) {
|
|
152
155
|
for (const file of previous.files) {
|
|
153
|
-
if (!kept.has(file.path)) await removeReplicaFile(
|
|
156
|
+
if (!kept.has(file.path)) await removeReplicaFile(replica, previous.path, file.path);
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
|
-
return
|
|
159
|
+
return writeScope(replica, scope, signal);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function removalsFirst(entries, removal) {
|
|
163
|
+
return [...entries.filter(removal), ...entries.filter((entry) => !removal(entry))];
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
function stringList(value) {
|
|
@@ -173,11 +180,17 @@ function operationsOf(value) {
|
|
|
173
180
|
function authorityRecord(scope, discovery, files) {
|
|
174
181
|
const found = matchedDiscovery(discovery, scope);
|
|
175
182
|
const operations = found === undefined ? null : operationsOf(found.operations);
|
|
176
|
-
if (
|
|
183
|
+
if (
|
|
184
|
+
scope.manifest?.scope !== "room" ||
|
|
185
|
+
typeof scope.manifest.anchor !== "string" ||
|
|
186
|
+
found === undefined ||
|
|
187
|
+
!stringList(found.protected_paths) ||
|
|
188
|
+
operations === null
|
|
189
|
+
) {
|
|
177
190
|
throw new KitRefusal("kit_bootstrap_rejected");
|
|
178
191
|
}
|
|
179
192
|
return {
|
|
180
|
-
kind:
|
|
193
|
+
kind: "room",
|
|
181
194
|
authority: scope.manifest.anchor,
|
|
182
195
|
room_handle: scope.manifest.room_handle,
|
|
183
196
|
path: scope.manifest.path,
|
|
@@ -192,9 +205,10 @@ function authorityRecord(scope, discovery, files) {
|
|
|
192
205
|
async function materialize(options, discovery, bundle, signal, previous = null) {
|
|
193
206
|
const authorities = [];
|
|
194
207
|
for (const scope of bundle.scopes) {
|
|
208
|
+
const record = authorityRecord(scope, discovery, []);
|
|
195
209
|
const held = previous?.authorities.find((entry) => entry.authority === scope.manifest?.anchor);
|
|
196
210
|
const files = await replaceScope(options.replica, held, scope, signal);
|
|
197
|
-
authorities.push(
|
|
211
|
+
authorities.push({ ...record, files });
|
|
198
212
|
}
|
|
199
213
|
const state = {
|
|
200
214
|
version: EXPLICIT_SYNC_VERSION,
|
|
@@ -241,10 +255,10 @@ async function applyEnumeration(options, state, enumeration, signal) {
|
|
|
241
255
|
if (authority === undefined) throw new KitRefusal("kit_sync_rejected", view.authority);
|
|
242
256
|
const reached = changePosition(view, "enumerate");
|
|
243
257
|
const recorded = new Map(authority.files.map((file) => [file.path, file]));
|
|
244
|
-
for (const change of view.changes ?? []) {
|
|
258
|
+
for (const change of removalsFirst(view.changes ?? [], (change) => change?.removed === true)) {
|
|
245
259
|
if (typeof change?.path !== "string") throw new KitRefusal("kit_sync_rejected", "enumerate");
|
|
246
260
|
if (change.removed === true) {
|
|
247
|
-
await removeReplicaFile(
|
|
261
|
+
await removeReplicaFile(options.replica, authority.path, change.path);
|
|
248
262
|
recorded.delete(change.path);
|
|
249
263
|
continue;
|
|
250
264
|
}
|
|
@@ -301,7 +315,7 @@ async function acceptBatch(options, state, outcome, signal, pending = null) {
|
|
|
301
315
|
const reached = changePosition(outcome, "batch");
|
|
302
316
|
const recorded = new Map(authority.files.map((file) => [file.path, file]));
|
|
303
317
|
const predicted = predictedReplica(authority, pending);
|
|
304
|
-
for (const file of outcome.contents ?? []) {
|
|
318
|
+
for (const file of removalsFirst(outcome.contents ?? [], (file) => file?.content === null)) {
|
|
305
319
|
const relative = relativeTo(authority.path, file.path);
|
|
306
320
|
const destination = locate(options.replica, authority.path, relative);
|
|
307
321
|
const local = await replicaText(destination);
|
|
@@ -315,7 +329,7 @@ async function acceptBatch(options, state, outcome, signal, pending = null) {
|
|
|
315
329
|
local !== accepted &&
|
|
316
330
|
local !== expected;
|
|
317
331
|
if (accepted === null) {
|
|
318
|
-
if (!preserve) await removeReplicaFile(
|
|
332
|
+
if (!preserve) await removeReplicaFile(options.replica, authority.path, relative);
|
|
319
333
|
recorded.delete(relative);
|
|
320
334
|
continue;
|
|
321
335
|
}
|
|
@@ -409,6 +423,7 @@ export async function bootstrap(options, dependencies = {}) {
|
|
|
409
423
|
const canonical = await canonicalReplica(options.replica);
|
|
410
424
|
const existing = await readReplicaState(options.directory, canonical);
|
|
411
425
|
if (existing !== null) throw new KitRefusal("kit_sync_already_bootstrapped", canonical);
|
|
426
|
+
await clearPending(options.directory, canonical);
|
|
412
427
|
return withSession(options, dependencies, async (session, signal) => {
|
|
413
428
|
const discovery = discoveryOf(
|
|
414
429
|
await door(session, KIT_DOOR_DISCOVER_PATH, { prefixes: options.prefixes }, signal),
|
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
|
+
}
|