@agentshouse/kit 0.0.1 → 0.1.0-alpha.10

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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1776 -2
  3. package/bin/connect-linux.sh +173 -0
  4. package/bin/kit.js +1023 -0
  5. package/bootstrap.json +17 -0
  6. package/package.json +37 -3
  7. package/src/acp.js +99 -0
  8. package/src/adapter-mcp.js +44 -0
  9. package/src/adapter.js +252 -0
  10. package/src/agent-logins.js +65 -0
  11. package/src/agents.js +504 -0
  12. package/src/apply.js +29 -0
  13. package/src/attach.js +52 -0
  14. package/src/binding.js +90 -0
  15. package/src/blocks.js +156 -0
  16. package/src/bridge.js +449 -0
  17. package/src/bytes.js +89 -0
  18. package/src/channel.js +37 -0
  19. package/src/command.js +109 -0
  20. package/src/configuration.js +465 -0
  21. package/src/connector-login.js +91 -0
  22. package/src/connector.js +107 -0
  23. package/src/continuation.js +61 -0
  24. package/src/contract.js +363 -0
  25. package/src/conversation-originals.js +143 -0
  26. package/src/custom/authorization.js +63 -0
  27. package/src/declaration.js +35 -0
  28. package/src/delivery.js +191 -0
  29. package/src/directory.js +102 -0
  30. package/src/draft.js +89 -0
  31. package/src/google/calendar.js +177 -0
  32. package/src/google/declared.js +146 -0
  33. package/src/google/drive.js +435 -0
  34. package/src/google/gateway.js +166 -0
  35. package/src/google/gmail.js +249 -0
  36. package/src/google/id.js +1 -0
  37. package/src/google/oauth.js +273 -0
  38. package/src/google/source.js +25 -0
  39. package/src/hook.js +376 -0
  40. package/src/host.js +121 -0
  41. package/src/house-main.js +79 -0
  42. package/src/house.js +1275 -0
  43. package/src/inbound.js +175 -0
  44. package/src/instance/google.js +1 -0
  45. package/src/kit-work-stream.js +84 -0
  46. package/src/launcher-main.js +19 -0
  47. package/src/launcher.js +276 -0
  48. package/src/local-files.js +113 -0
  49. package/src/login.js +274 -0
  50. package/src/mcp.js +71 -0
  51. package/src/migrator.js +248 -0
  52. package/src/mount.js +582 -0
  53. package/src/operation.js +20 -0
  54. package/src/original.js +631 -0
  55. package/src/peer.js +124 -0
  56. package/src/plan.js +57 -0
  57. package/src/question.js +316 -0
  58. package/src/refusal.js +79 -0
  59. package/src/repository.js +521 -0
  60. package/src/session-bin/house +2 -0
  61. package/src/settlement.js +57 -0
  62. package/src/setup.js +190 -0
  63. package/src/source-originals.js +116 -0
  64. package/src/source.js +290 -0
  65. package/src/starter-set/grilling/LICENSE +21 -0
  66. package/src/starter-set/grilling/SKILL.md +30 -0
  67. package/src/starter-set/grilling/agents/openai.yaml +3 -0
  68. package/src/starter-set.js +17 -0
  69. package/src/state.js +157 -0
  70. package/src/sync-library.js +293 -0
  71. package/src/sync-state.js +120 -0
  72. package/src/sync.js +482 -0
  73. package/src/telegram.js +1061 -0
  74. package/src/terminal.js +57 -0
  75. package/src/tmux.js +119 -0
  76. package/src/worker.js +928 -0
  77. package/src/workspace.js +500 -0
package/src/state.js ADDED
@@ -0,0 +1,157 @@
1
+ import { chmodSync, renameSync } from "node:fs";
2
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { DECLARED_ID, DROPPED_ITEM_NOTES_MAX, SHA256_DIGEST } from "./contract.js";
5
+ import { KitRefusal } from "./refusal.js";
6
+
7
+ function segment(name) {
8
+ if (!DECLARED_ID.test(name)) throw new KitRefusal("kit_state_name_invalid", name);
9
+ return name;
10
+ }
11
+
12
+ function notePath(directory, instance, service, kind) {
13
+ return join(directory, "state", segment(instance), kind, `${segment(service)}.json`);
14
+ }
15
+
16
+ function repositoryPath(directory, key) {
17
+ if (!/^[a-f0-9]{64}$/.test(key)) throw new KitRefusal("kit_state_name_invalid", key);
18
+ return join(directory, "state", "repositories", `${key}.json`);
19
+ }
20
+
21
+ export async function writeText(path, contents, signal, mode = 0o600) {
22
+ await mkdir(join(path, ".."), { recursive: true, mode: 0o700 });
23
+ if (signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
24
+ const temporary = `${path}.${process.pid}.tmp`;
25
+ try {
26
+ await writeFile(temporary, contents, { mode, signal });
27
+ if (signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
28
+ chmodSync(temporary, mode);
29
+ renameSync(temporary, path);
30
+ } catch (error) {
31
+ await rm(temporary, { force: true });
32
+ if (signal?.aborted === true || error?.name === "AbortError") {
33
+ throw new KitRefusal("kit_command_cancelled");
34
+ }
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ export async function writeNote(path, value, signal, mode) {
40
+ return writeText(path, `${JSON.stringify(value, null, 2)}\n`, signal, mode);
41
+ }
42
+
43
+ export async function readNote(path, absent) {
44
+ let stored;
45
+ try {
46
+ stored = await readFile(path, "utf8");
47
+ } catch (error) {
48
+ if (error.code === "ENOENT") return absent;
49
+ throw new KitRefusal("kit_state_unreadable", path);
50
+ }
51
+ try {
52
+ return JSON.parse(stored);
53
+ } catch {
54
+ throw new KitRefusal("kit_state_unreadable", path);
55
+ }
56
+ }
57
+
58
+ export async function readCheckpoint(directory, instance, service) {
59
+ return readNote(notePath(directory, instance, service, "checkpoints"), null);
60
+ }
61
+
62
+ export async function writeCheckpoint(directory, instance, service, checkpoint) {
63
+ return writeNote(notePath(directory, instance, service, "checkpoints"), checkpoint);
64
+ }
65
+
66
+ export async function readRepositoryState(directory, key) {
67
+ return readNote(repositoryPath(directory, key), null);
68
+ }
69
+
70
+ export async function writeRepositoryState(directory, key, state, signal) {
71
+ return writeNote(repositoryPath(directory, key), state, signal);
72
+ }
73
+
74
+ function attachmentPath(directory, key) {
75
+ if (!SHA256_DIGEST.test(key)) throw new KitRefusal("kit_state_name_invalid", key);
76
+ return join(directory, "state", "attachments", `${key}.json`);
77
+ }
78
+
79
+ export async function readAttachmentState(directory, key) {
80
+ return readNote(attachmentPath(directory, key), null);
81
+ }
82
+
83
+ export async function writeAttachmentState(directory, key, state, signal) {
84
+ return writeNote(attachmentPath(directory, key), state, signal);
85
+ }
86
+
87
+ export async function readSourceOriginals(directory, instance, service) {
88
+ return readNote(notePath(directory, instance, service, "originals"), []);
89
+ }
90
+
91
+ export async function writeSourceOriginals(directory, instance, service, references, signal) {
92
+ const path = notePath(directory, instance, service, "originals");
93
+ if (references.length > 0) return writeNote(path, references, signal);
94
+ await rm(path, { force: true });
95
+ }
96
+
97
+ export async function readHealth(directory, instance, service) {
98
+ return readNote(notePath(directory, instance, service, "health"), null);
99
+ }
100
+
101
+ export async function recordHealth(directory, instance, service, health) {
102
+ return writeNote(notePath(directory, instance, service, "health"), health);
103
+ }
104
+
105
+ export async function readCoverageGaps(directory, instance, service) {
106
+ return readNote(notePath(directory, instance, service, "gaps"), []);
107
+ }
108
+
109
+ export async function recordCoverageGaps(directory, instance, service, gaps) {
110
+ const kept = await readCoverageGaps(directory, instance, service);
111
+ const merged = kept.filter((note) => !gaps.some((gap) => gap.room === note.room));
112
+ for (const gap of gaps) {
113
+ const known = kept.find((note) => note.room === gap.room);
114
+ merged.push({ ...gap, cursor: known === undefined ? gap.cursor : known.cursor });
115
+ }
116
+ await writeNote(notePath(directory, instance, service, "gaps"), merged);
117
+ }
118
+
119
+ export async function readDroppedItems(directory, instance, service) {
120
+ return readNote(notePath(directory, instance, service, "dropped"), []);
121
+ }
122
+
123
+ export async function recordDroppedItems(directory, instance, service, notes) {
124
+ if (notes.length === 0) return;
125
+ const kept = [...(await readDroppedItems(directory, instance, service)), ...notes];
126
+ await writeNote(
127
+ notePath(directory, instance, service, "dropped"),
128
+ kept.slice(-DROPPED_ITEM_NOTES_MAX),
129
+ );
130
+ }
131
+
132
+ export async function resetCheckpoints(directory, instance, service) {
133
+ const checkpoints = join(directory, "state", segment(instance), "checkpoints");
134
+ if (service !== null) {
135
+ const path = notePath(directory, instance, service, "checkpoints");
136
+ await rm(notePath(directory, instance, service, "originals"), { force: true });
137
+ try {
138
+ await rm(path);
139
+ } catch (error) {
140
+ if (error.code === "ENOENT") return [];
141
+ throw new KitRefusal("kit_state_unreadable", path);
142
+ }
143
+ return [service];
144
+ }
145
+ let present;
146
+ try {
147
+ present = await readdir(checkpoints);
148
+ } catch {
149
+ return [];
150
+ }
151
+ const reset = present.filter((name) => name.endsWith(".json")).sort();
152
+ for (const name of reset) {
153
+ await rm(join(directory, "state", segment(instance), "originals", name), { force: true });
154
+ await rm(join(checkpoints, name));
155
+ }
156
+ return reset.map((name) => name.slice(0, -".json".length));
157
+ }
@@ -0,0 +1,293 @@
1
+ import { apply, parse, serialize, VERSION } from "@agentshouse/mdmodel";
2
+ import { KitRefusal } from "./refusal.js";
3
+
4
+ export { VERSION };
5
+
6
+ function covers(path, prefix) {
7
+ const normalized = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
8
+ return path === normalized || path.startsWith(`${normalized}/`);
9
+ }
10
+
11
+ export function relativeTo(authorityPath, housePath) {
12
+ if (housePath === authorityPath) return "";
13
+ const prefix = `${authorityPath}/`;
14
+ if (!housePath.startsWith(prefix)) throw new KitRefusal("kit_sync_path_invalid", housePath);
15
+ return housePath.slice(prefix.length);
16
+ }
17
+
18
+ export function housePath(authorityPath, relative) {
19
+ if (relative === "") return authorityPath;
20
+ return `${authorityPath}/${relative}`;
21
+ }
22
+
23
+ export function authorityFor(state, path) {
24
+ return state.authorities.find((entry) => path === entry.path || path.startsWith(`${entry.path}/`));
25
+ }
26
+
27
+ export function insideSelection(state, path) {
28
+ const prefixes = Array.isArray(state.prefixes) ? state.prefixes : [];
29
+ if (prefixes.some((prefix) => covers(path, prefix))) return true;
30
+ const authority = authorityFor(state, path);
31
+ if (authority === undefined) return false;
32
+ if (relativeTo(authority.path, path) !== "ROOM.md") return false;
33
+ return prefixes.some(
34
+ (prefix) => prefix === authority.path || prefix.startsWith(`${authority.path}/`),
35
+ );
36
+ }
37
+
38
+ export const OPERATIONS = ["read", "sync", "write", "delete"];
39
+
40
+ export function admitsOperation(relative, authority, operation) {
41
+ return authority.operations[operation].some(
42
+ (scope) => scope === "" || covers(relative, scope.replace(/^\//, "")),
43
+ );
44
+ }
45
+
46
+ export function isProtected(relative, authority) {
47
+ return authority.protected_paths.some((prefix) => {
48
+ const trimmed = prefix.startsWith(authority.path)
49
+ ? prefix.slice(authority.path.length).replace(/^\//, "")
50
+ : prefix.replace(/^\//, "");
51
+ return trimmed !== "" && covers(relative, trimmed);
52
+ });
53
+ }
54
+
55
+ export function isGeneratedProvenance(relative) {
56
+ return relative.split("/").includes("_provenance");
57
+ }
58
+
59
+ export function isAuthored(relative, authority) {
60
+ if (isProtected(relative, authority)) return false;
61
+ if (isGeneratedProvenance(relative)) return false;
62
+ return relative === "ROOM.md" || relative.startsWith("library/");
63
+ }
64
+
65
+ export function isWritable(relative, authority) {
66
+ return isAuthored(relative, authority) && admitsOperation(relative, authority, "write");
67
+ }
68
+
69
+ export function unknownPathRefusal(path) {
70
+ const refusal = new KitRefusal("protected_path", path);
71
+ refusal.payload = { error: { code: "protected_path", path } };
72
+ return refusal;
73
+ }
74
+
75
+ export function protectedPathRefusal(authority, relative) {
76
+ return unknownPathRefusal(housePath(authority.path, relative));
77
+ }
78
+
79
+ export function operationDeniedRefusal(authority, relative, operation) {
80
+ const path = housePath(authority.path, relative);
81
+ const refusal = new KitRefusal("operation_denied", path);
82
+ refusal.payload = { error: { code: "operation_denied", path, operation } };
83
+ return refusal;
84
+ }
85
+
86
+ export function assertAdmitted(authority, relative, operation) {
87
+ if (!isAuthored(relative, authority)) throw protectedPathRefusal(authority, relative);
88
+ if (!admitsOperation(relative, authority, operation)) {
89
+ throw operationDeniedRefusal(authority, relative, operation);
90
+ }
91
+ }
92
+
93
+ export function sidecarPath(path) {
94
+ const segments = path.split("/");
95
+ segments.splice(-1, 0, "_provenance");
96
+ return segments.join("/");
97
+ }
98
+
99
+ function recordedMap(authority) {
100
+ return new Map(authority.files.map((file) => [file.path, file]));
101
+ }
102
+
103
+ function structuredOrReplace(root, relative, recorded, content) {
104
+ const path = housePath(root, relative);
105
+ const before = parse(recorded.content, relative);
106
+ const after = parse(content, relative);
107
+ if (!before.ok || !after.ok) {
108
+ return [{ op: "replace", path, base: recorded.revision, content }];
109
+ }
110
+ const ops = [];
111
+ if (before.document.preamble !== after.document.preamble) {
112
+ ops.push({
113
+ op: "replace_preamble",
114
+ path,
115
+ base: before.document.preamble,
116
+ content: after.document.preamble,
117
+ });
118
+ }
119
+ const fields = new Set([
120
+ ...Object.keys(before.document.fields),
121
+ ...Object.keys(after.document.fields),
122
+ ]);
123
+ for (const name of fields) {
124
+ if (JSON.stringify(before.document.fields[name]) === JSON.stringify(after.document.fields[name])) {
125
+ continue;
126
+ }
127
+ if (after.document.fields[name] === undefined) {
128
+ return [{ op: "replace", path, base: recorded.revision, content }];
129
+ }
130
+ const change = { op: "set_field", path, field: name, value: after.document.fields[name] };
131
+ if (before.document.fields[name] !== undefined) change.base = before.document.fields[name];
132
+ ops.push(change);
133
+ }
134
+ const sections = new Set([
135
+ ...Object.keys(before.document.sections),
136
+ ...Object.keys(after.document.sections),
137
+ ]);
138
+ for (const name of sections) {
139
+ const prior = before.document.sections[name];
140
+ const next = after.document.sections[name];
141
+ if (prior === next) continue;
142
+ if (prior === undefined || next === undefined) {
143
+ return [{ op: "replace", path, base: recorded.revision, content }];
144
+ }
145
+ ops.push({ op: "replace_section", path, section: name, base: prior, content: next });
146
+ }
147
+ return ops.length === 0 ? [{ op: "replace", path, base: recorded.revision, content }] : ops;
148
+ }
149
+
150
+ export function deriveChanges(state, authority, localFiles) {
151
+ const recorded = recordedMap(authority);
152
+ const changes = [];
153
+ for (const [path, content] of Object.entries(localFiles)) {
154
+ const held = recorded.get(path);
155
+ if (!isWritable(path, authority)) {
156
+ if (held !== undefined && held.content === content) continue;
157
+ if (!insideSelection(state, housePath(authority.path, path))) {
158
+ throw protectedPathRefusal(authority, path);
159
+ }
160
+ assertAdmitted(authority, path, "write");
161
+ }
162
+ if (held === undefined) {
163
+ changes.push({ op: "create", path: housePath(authority.path, path), content });
164
+ continue;
165
+ }
166
+ if (held.content === content) continue;
167
+ changes.push(...structuredOrReplace(authority.path, path, held, content));
168
+ }
169
+ return changes;
170
+ }
171
+
172
+ function typedContent(change, relative) {
173
+ const sections = change.sections ?? {};
174
+ const fields = change.fields ?? {};
175
+ const parsed = parse(
176
+ `---\ntype: ${JSON.stringify(change.type)}\n---\n${Object.keys(sections)
177
+ .map((name) => `## ${name}\n`)
178
+ .join("")}`,
179
+ relative,
180
+ );
181
+ if (!parsed.ok) return undefined;
182
+ let document = parsed.document;
183
+ for (const [name, value] of Object.entries(fields)) {
184
+ const result = apply(document, { kind: "field", name, value, prior: undefined });
185
+ if (result.outcome !== "applied") return undefined;
186
+ document = result.document;
187
+ }
188
+ for (const [name, value] of Object.entries(sections)) {
189
+ const result = apply(document, { kind: "section", name, value, prior: "" });
190
+ if (result.outcome !== "applied") return undefined;
191
+ document = result.document;
192
+ }
193
+ return serialize(document);
194
+ }
195
+
196
+ function patchedContent(content, find, replace) {
197
+ if (typeof content !== "string" || typeof find !== "string" || find.length === 0) return undefined;
198
+ let count = 0;
199
+ let index = content.indexOf(find);
200
+ while (index !== -1) {
201
+ count += 1;
202
+ index = content.indexOf(find, index + find.length);
203
+ }
204
+ if (count !== 1) return undefined;
205
+ const at = content.indexOf(find);
206
+ return `${content.slice(0, at)}${replace}${content.slice(at + find.length)}`;
207
+ }
208
+
209
+ function applyRename(files, from, to) {
210
+ const directory = typeof files[from] !== "string";
211
+ const moved = Object.entries(files).filter(([path]) =>
212
+ directory ? path.startsWith(`${from}/`) : path === from,
213
+ );
214
+ for (const [path] of moved) delete files[path];
215
+ for (const [path, content] of moved) files[`${to}${path.slice(from.length)}`] = content;
216
+ }
217
+
218
+ export function applyChanges(files, changes, authorityPath) {
219
+ const next = { ...files };
220
+ for (const change of changes) {
221
+ const relative = relativeTo(authorityPath, change.path);
222
+ if (change.op === "create") {
223
+ next[relative] = change.content;
224
+ continue;
225
+ }
226
+ if (change.op === "replace") {
227
+ next[relative] = change.content;
228
+ continue;
229
+ }
230
+ if (change.op === "remove") {
231
+ delete next[relative];
232
+ continue;
233
+ }
234
+ if (change.op === "rename") {
235
+ applyRename(next, relative, relativeTo(authorityPath, change.to));
236
+ continue;
237
+ }
238
+ if (change.op === "create_typed") {
239
+ const content = typedContent(change, relative);
240
+ if (content !== undefined) next[relative] = content;
241
+ continue;
242
+ }
243
+ if (change.op === "patch") {
244
+ const content = patchedContent(next[relative], change.find, change.replace);
245
+ if (content !== undefined) next[relative] = content;
246
+ continue;
247
+ }
248
+ if (change.op !== "set_field" && change.op !== "replace_section" && change.op !== "replace_preamble") {
249
+ continue;
250
+ }
251
+ const current = next[relative];
252
+ if (typeof current !== "string") continue;
253
+ const parsed = parse(current, relative);
254
+ if (!parsed.ok) continue;
255
+ const structured =
256
+ change.op === "set_field"
257
+ ? { kind: "field", name: change.field, value: change.value, prior: change.base }
258
+ : change.op === "replace_preamble"
259
+ ? { kind: "preamble", value: change.content, prior: change.base }
260
+ : { kind: "section", name: change.section, value: change.content, prior: change.base };
261
+ const result = apply(parsed.document, structured);
262
+ if (result.outcome === "applied") next[relative] = serialize(result.document);
263
+ }
264
+ return next;
265
+ }
266
+
267
+ export function resultingFiles(authority, changes) {
268
+ const files = {};
269
+ for (const file of authority.files) files[file.path] = file.content;
270
+ return applyChanges(files, changes, authority.path);
271
+ }
272
+
273
+ function namedPaths(change) {
274
+ if (change.op !== "rename") return [change.path];
275
+ const locations = [change.path, change.to];
276
+ if (change.base === null || typeof change.base !== "object") return locations;
277
+ const members = Object.keys(change.base).flatMap((member) =>
278
+ locations.map((location) => `${location}/${member}`),
279
+ );
280
+ return [...locations, ...members];
281
+ }
282
+
283
+ export function assertWritableChanges(state, changes) {
284
+ for (const change of changes) {
285
+ const operation = change.op === "remove" ? "delete" : "write";
286
+ for (const path of namedPaths(change)) {
287
+ const authority = authorityFor(state, path);
288
+ if (authority === undefined) throw unknownPathRefusal(path);
289
+ if (!insideSelection(state, path)) throw unknownPathRefusal(path);
290
+ assertAdmitted(authority, relativeTo(authority.path, path), operation);
291
+ }
292
+ }
293
+ }
@@ -0,0 +1,120 @@
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";
4
+ import { EXPLICIT_SYNC_VERSION } from "./contract.js";
5
+ import { KitRefusal } from "./refusal.js";
6
+ import { readNote, writeNote, writeText } from "./state.js";
7
+ import { OPERATIONS } from "./sync-library.js";
8
+
9
+ export { EXPLICIT_SYNC_VERSION };
10
+
11
+ export function replicaKey(canonical) {
12
+ return createHash("sha256").update(canonical).digest("hex");
13
+ }
14
+
15
+ export async function canonicalReplica(replica) {
16
+ await mkdir(replica, { recursive: true, mode: 0o755 });
17
+ return realpath(replica);
18
+ }
19
+
20
+ function syncFile(directory, key, kind = "") {
21
+ if (!/^[a-f0-9]{64}$/.test(key)) throw new KitRefusal("kit_state_name_invalid", key);
22
+ const name = kind === "" ? `${key}.json` : `${key}.${kind}.json`;
23
+ return join(directory, "state", "sync", name);
24
+ }
25
+
26
+ function recordedAuthority(authority) {
27
+ const operations = authority?.operations;
28
+ return (
29
+ authority?.kind === "room" &&
30
+ typeof authority.authority === "string" &&
31
+ typeof authority.path === "string" &&
32
+ typeof authority?.log_epoch === "string" &&
33
+ operations !== null &&
34
+ typeof operations === "object" &&
35
+ OPERATIONS.every((operation) => Array.isArray(operations[operation]))
36
+ );
37
+ }
38
+
39
+ export async function readReplicaState(directory, replica) {
40
+ let canonical;
41
+ try {
42
+ canonical = await realpath(replica);
43
+ } catch (error) {
44
+ if (error.code === "ENOENT") return null;
45
+ throw error;
46
+ }
47
+ const state = await readNote(syncFile(directory, replicaKey(canonical)), null);
48
+ if (state === null) return null;
49
+ if (state.version !== EXPLICIT_SYNC_VERSION) return null;
50
+ if (!Array.isArray(state.authorities) || !state.authorities.every(recordedAuthority)) return null;
51
+ return state;
52
+ }
53
+
54
+ export async function writeReplicaState(directory, state, signal) {
55
+ return writeNote(syncFile(directory, replicaKey(state.directory)), state, signal);
56
+ }
57
+
58
+ export async function readPending(directory, canonical) {
59
+ return readNote(syncFile(directory, replicaKey(canonical), "pending"), null);
60
+ }
61
+
62
+ export async function writePending(directory, canonical, pending, signal) {
63
+ return writeNote(syncFile(directory, replicaKey(canonical), "pending"), pending, signal);
64
+ }
65
+
66
+ export async function clearPending(directory, canonical) {
67
+ const path = syncFile(directory, replicaKey(canonical), "pending");
68
+ try {
69
+ await rm(path);
70
+ } catch (error) {
71
+ if (error.code !== "ENOENT") throw new KitRefusal("kit_state_unreadable", path);
72
+ }
73
+ }
74
+
75
+ export function locate(replica, rootPath, relative = "") {
76
+ const base = join(replica, rootPath.replace(/^\//, ""));
77
+ const target = resolve(base, relative);
78
+ if (target !== base && !target.startsWith(`${base}/`)) {
79
+ throw new KitRefusal("kit_sync_path_invalid", relative);
80
+ }
81
+ return target;
82
+ }
83
+
84
+ export async function writeReplicaFile(path, content, signal) {
85
+ return writeText(path, content, signal, 0o644);
86
+ }
87
+
88
+ export async function removeReplicaFile(path) {
89
+ try {
90
+ await rm(path);
91
+ } catch (error) {
92
+ if (error.code !== "ENOENT") throw new KitRefusal("kit_state_unreadable", path);
93
+ }
94
+ }
95
+
96
+ export async function listFiles(root) {
97
+ let entries;
98
+ try {
99
+ entries = await readdir(root, { recursive: true, withFileTypes: true });
100
+ } catch (error) {
101
+ if (error.code === "ENOENT") return [];
102
+ throw error;
103
+ }
104
+ const files = [];
105
+ for (const entry of entries) {
106
+ if (!entry.isFile()) continue;
107
+ const full = join(entry.parentPath ?? root, entry.name);
108
+ files.push(relative(root, full).split("\\").join("/"));
109
+ }
110
+ return files.sort();
111
+ }
112
+
113
+ export async function readLocalMap(replica, authorityPath) {
114
+ const root = locate(replica, authorityPath);
115
+ const files = {};
116
+ for (const path of await listFiles(root)) {
117
+ files[path] = await readFile(join(root, path), "utf8");
118
+ }
119
+ return files;
120
+ }