@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.
Files changed (142) hide show
  1. package/README.md +697 -260
  2. package/bin/connect-linux.sh +200 -25
  3. package/bin/kit.js +179 -157
  4. package/bootstrap.json +17 -0
  5. package/package.json +7 -2
  6. package/src/adapter-mcp.js +76 -12
  7. package/src/agents.js +63 -8
  8. package/src/binding.js +52 -0
  9. package/src/blocks.js +2 -26
  10. package/src/bridge.js +28 -14
  11. package/src/bytes.js +19 -8
  12. package/src/configuration.js +13 -209
  13. package/src/connector-login.js +3 -84
  14. package/src/contract.js +36 -11
  15. package/src/delivery.js +4 -7
  16. package/src/directory.js +6 -26
  17. package/src/draft.js +4 -82
  18. package/src/git-main.js +138 -0
  19. package/src/git.js +62 -0
  20. package/src/google/calendar.js +39 -45
  21. package/src/google/drive.js +61 -186
  22. package/src/google/entries.js +346 -0
  23. package/src/google/gateway.js +3 -6
  24. package/src/google/gmail.js +33 -23
  25. package/src/google/oauth.js +106 -66
  26. package/src/google/source.js +21 -23
  27. package/src/host.js +6 -2
  28. package/src/house-main.js +65 -8
  29. package/src/house.js +446 -127
  30. package/src/how-we-work.md +25 -0
  31. package/src/inbound.js +2 -18
  32. package/src/kit-work-stream.js +118 -0
  33. package/src/launcher.js +46 -10
  34. package/src/local-files.js +3 -11
  35. package/src/login.js +23 -11
  36. package/src/mcp.js +18 -0
  37. package/src/mount.js +13 -21
  38. package/src/original.js +4 -4
  39. package/src/pacing.js +41 -0
  40. package/src/plan.js +37 -45
  41. package/src/repository.js +3 -61
  42. package/src/session-bin/git +2 -0
  43. package/src/setup.js +14 -15
  44. package/src/skill-set/ask-matt/PHASE-BOUNDARIES.md +55 -0
  45. package/src/skill-set/ask-matt/SKILL.md +91 -0
  46. package/src/skill-set/ask-matt/agents/openai.yaml +4 -0
  47. package/src/skill-set/code-review/LICENSE +21 -0
  48. package/src/skill-set/code-review/SKILL.md +89 -0
  49. package/src/skill-set/code-review/agents/openai.yaml +3 -0
  50. package/src/skill-set/codebase-design/DEEPENING.md +37 -0
  51. package/src/skill-set/codebase-design/DESIGN-IT-TWICE.md +44 -0
  52. package/src/skill-set/codebase-design/LICENSE +21 -0
  53. package/src/skill-set/codebase-design/SKILL.md +116 -0
  54. package/src/skill-set/codebase-design/agents/openai.yaml +3 -0
  55. package/src/skill-set/diagnosing-bugs/LICENSE +21 -0
  56. package/src/skill-set/diagnosing-bugs/SKILL.md +140 -0
  57. package/src/skill-set/diagnosing-bugs/agents/openai.yaml +3 -0
  58. package/src/skill-set/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  59. package/src/skill-set/domain-modeling/ADR-FORMAT.md +47 -0
  60. package/src/skill-set/domain-modeling/CONTEXT-FORMAT.md +60 -0
  61. package/src/skill-set/domain-modeling/LICENSE +21 -0
  62. package/src/skill-set/domain-modeling/SKILL.md +76 -0
  63. package/src/skill-set/domain-modeling/agents/openai.yaml +3 -0
  64. package/src/skill-set/grill-me/LICENSE +21 -0
  65. package/src/skill-set/grill-me/SKILL.md +8 -0
  66. package/src/skill-set/grill-me/agents/openai.yaml +4 -0
  67. package/src/skill-set/grill-with-docs/LICENSE +21 -0
  68. package/src/skill-set/grill-with-docs/SKILL.md +8 -0
  69. package/src/skill-set/grill-with-docs/agents/openai.yaml +4 -0
  70. package/src/skill-set/grilling/LICENSE +21 -0
  71. package/src/{starter-set → skill-set}/grilling/SKILL.md +1 -1
  72. package/src/skill-set/handoff/LICENSE +21 -0
  73. package/src/skill-set/handoff/SKILL.md +17 -0
  74. package/src/skill-set/handoff/agents/openai.yaml +4 -0
  75. package/src/skill-set/implement/LICENSE +21 -0
  76. package/src/skill-set/implement/SKILL.md +16 -0
  77. package/src/skill-set/implement/agents/openai.yaml +4 -0
  78. package/src/skill-set/improve-codebase-architecture/HTML-REPORT.md +123 -0
  79. package/src/skill-set/improve-codebase-architecture/LICENSE +21 -0
  80. package/src/skill-set/improve-codebase-architecture/SKILL.md +72 -0
  81. package/src/skill-set/improve-codebase-architecture/agents/openai.yaml +4 -0
  82. package/src/skill-set/prototype/LICENSE +21 -0
  83. package/src/skill-set/prototype/LOGIC.md +67 -0
  84. package/src/skill-set/prototype/SKILL.md +28 -0
  85. package/src/skill-set/prototype/UI.md +112 -0
  86. package/src/skill-set/prototype/agents/openai.yaml +3 -0
  87. package/src/skill-set/research/LICENSE +21 -0
  88. package/src/skill-set/research/SKILL.md +14 -0
  89. package/src/skill-set/research/agents/openai.yaml +3 -0
  90. package/src/skill-set/resolving-merge-conflicts/LICENSE +21 -0
  91. package/src/skill-set/resolving-merge-conflicts/SKILL.md +16 -0
  92. package/src/skill-set/resolving-merge-conflicts/agents/openai.yaml +3 -0
  93. package/src/skill-set/tdd/LICENSE +21 -0
  94. package/src/skill-set/tdd/SKILL.md +40 -0
  95. package/src/skill-set/tdd/agents/openai.yaml +3 -0
  96. package/src/skill-set/tdd/mocking.md +59 -0
  97. package/src/skill-set/tdd/tests.md +77 -0
  98. package/src/skill-set/teach/GLOSSARY-FORMAT.md +35 -0
  99. package/src/skill-set/teach/LEARNING-RECORD-FORMAT.md +46 -0
  100. package/src/skill-set/teach/LICENSE +21 -0
  101. package/src/skill-set/teach/MISSION-FORMAT.md +31 -0
  102. package/src/skill-set/teach/RESOURCES-FORMAT.md +32 -0
  103. package/src/skill-set/teach/SKILL.md +141 -0
  104. package/src/skill-set/teach/agents/openai.yaml +4 -0
  105. package/src/skill-set/to-questionnaire/LICENSE +21 -0
  106. package/src/skill-set/to-questionnaire/SKILL.md +55 -0
  107. package/src/skill-set/to-questionnaire/agents/openai.yaml +4 -0
  108. package/src/skill-set/to-spec/LICENSE +21 -0
  109. package/src/skill-set/to-spec/SKILL.md +76 -0
  110. package/src/skill-set/to-spec/agents/openai.yaml +4 -0
  111. package/src/skill-set/to-tickets/LICENSE +21 -0
  112. package/src/skill-set/to-tickets/SKILL.md +106 -0
  113. package/src/skill-set/to-tickets/agents/openai.yaml +4 -0
  114. package/src/skill-set/wait-what/LICENSE +21 -0
  115. package/src/skill-set/wait-what/SKILL.md +8 -0
  116. package/src/skill-set/wait-what/agents/openai.yaml +4 -0
  117. package/src/skill-set/wayfinder/LICENSE +21 -0
  118. package/src/skill-set/wayfinder/SKILL.md +129 -0
  119. package/src/skill-set/wayfinder/agents/openai.yaml +4 -0
  120. package/src/skill-set/writing-for-agents/LICENSE +21 -0
  121. package/src/skill-set/writing-for-agents/SKILL-MECHANICS.md +22 -0
  122. package/src/skill-set/writing-for-agents/SKILL.md +83 -0
  123. package/src/skill-set/writing-for-agents/agents/openai.yaml +3 -0
  124. package/src/skill-set.js +85 -0
  125. package/src/source.js +93 -54
  126. package/src/state.js +19 -1
  127. package/src/sync-library.js +7 -11
  128. package/src/sync-state.js +16 -3
  129. package/src/sync.js +36 -21
  130. package/src/tasks.js +75 -0
  131. package/src/telegram-view.js +214 -0
  132. package/src/telegram.js +161 -66
  133. package/src/turn-frames.js +69 -0
  134. package/src/worker.js +674 -184
  135. package/src/working-copy.js +1221 -0
  136. package/src/workspace.js +40 -32
  137. package/src/google/declared.js +0 -146
  138. package/src/google/id.js +0 -1
  139. package/src/instance/google.js +0 -1
  140. package/src/starter-set.js +0 -17
  141. /package/src/{starter-set/grilling → skill-set/ask-matt}/LICENSE +0 -0
  142. /package/src/{starter-set → skill-set}/grilling/agents/openai.yaml +0 -0
@@ -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 { googlePage } from "./google/source.js";
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 (context.instance.preset === "google") {
36
+ if (service.google !== undefined) {
34
37
  return googlePage(
35
38
  {
36
39
  directory: context.directory,
37
- instance: context.instance.id,
38
- client: context.googleOAuthClient,
39
- service,
40
+ account: service.google.account,
41
+ service: service.google.service,
40
42
  cursor: checkpoint.cursor,
41
43
  limit,
42
44
  backfillFloor:
@@ -169,6 +171,7 @@ async function deliverService(context, service) {
169
171
  context.instance.preset === "custom"
170
172
  ? { ...context, environment: await resolveEnvironment(service.connector) }
171
173
  : context;
174
+ if (service.google !== undefined) await recordAccountEmail(context, service.google);
172
175
  await runDeliveryLoop(resolved, service, progress);
173
176
  } catch (refusal) {
174
177
  if (!(refusal instanceof KitRefusal) || refusal.code === "kit_command_cancelled") throw refusal;
@@ -197,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
- const context = {
208
- instance,
229
+ return maintainControl(
209
230
  session,
210
- directory: options.directory,
211
- signal: options.signal,
212
- now: session.now,
213
- dependencies,
214
- googleOAuthClient,
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
- health: (await readHealth(options.directory, instance.id, service.service)) ?? {
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
- if (outstanding.some((count) => count > 0)) context.session = await openSession(options, dependencies);
264
- const retried = [];
265
- for (const [position, service] of services.entries()) {
266
- retried.push({
267
- service: service.service,
268
- originals:
269
- outstanding[position] === 0
270
- ? []
271
- : await saveSourceOriginals(context, serviceBinding(context, service)),
272
- });
273
- }
274
- return { instance: instance.id, services: retried };
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
- instance: instance.id,
284
- services: await resetCheckpoints(options.directory, instance.id, options.service),
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`);
@@ -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
- return ops.length === 0 ? [{ op: "replace", path, base: recorded.revision, content }] : ops;
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(path) {
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
- async function writeScope(replica, scope, signal) {
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 next = await writeScope(replica, scope, signal);
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(locate(replica, scope.manifest.path, path));
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(locate(replica, previous.path, file.path));
156
+ if (!kept.has(file.path)) await removeReplicaFile(replica, previous.path, file.path);
154
157
  }
155
158
  }
156
- return next;
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 (found === undefined || !stringList(found.protected_paths) || operations === null) {
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: scope.manifest.scope === "private" ? "private" : "room",
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(authorityRecord(scope, discovery, files));
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(locate(options.replica, authority.path, change.path));
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(destination);
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
+ }