@agentshouse/kit 0.1.0-alpha.17 → 0.1.0-alpha.18
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 +58 -8
- package/bin/connect-linux.sh +14 -4
- package/bootstrap.json +3 -3
- package/package.json +2 -1
- package/src/bridge.js +1 -1
- package/src/contract.js +5 -1
- package/src/house-main.js +65 -8
- package/src/house.js +50 -37
- package/src/local-files.js +3 -11
- package/src/mcp.js +13 -0
- package/src/sync-library.js +7 -11
- package/src/sync.js +1 -1
- package/src/worker.js +64 -6
- package/src/working-copy.js +516 -47
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ On Linux `amd64` or `arm64` with a working Docker Engine, run the published,
|
|
|
10
10
|
versioned bootstrap once:
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
13
|
+
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.18/connect-linux.sh)
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
The release asset pins one immutable multi-architecture image digest. It creates
|
|
@@ -105,6 +105,14 @@ Forwarding installs no host-native Kit and gives the container no Docker socket
|
|
|
105
105
|
or further mount; it refuses when no Kit is installed or the installed one
|
|
106
106
|
differs from this bootstrap's.
|
|
107
107
|
|
|
108
|
+
The owner's working copy push is forwarded the same way: run
|
|
109
|
+
`bash <(curl -fsSL …/connect-linux.sh) house git push [commit] --owner` from a
|
|
110
|
+
directory beneath the workspace root, such as a Room copy under
|
|
111
|
+
`working-copies/rooms/`. The bootstrap runs the container's `house` in the
|
|
112
|
+
matching directory beneath `/agents/house`, through the resident's lease when
|
|
113
|
+
the container runs, and refuses a directory outside the workspace before
|
|
114
|
+
anything reaches House.
|
|
115
|
+
|
|
108
116
|
## Building the package
|
|
109
117
|
|
|
110
118
|
The repository checkout is the development source. Build the package
|
|
@@ -829,7 +837,9 @@ nonzero exit status. The CLI holds
|
|
|
829
837
|
no credential: it calls over its Session's bridge, which attaches the Session
|
|
830
838
|
credential and refuses a caller without that Session's nonce as
|
|
831
839
|
`kit_bridge_unauthorized`. House network calls use only the selected
|
|
832
|
-
`2026-07-28` profile.
|
|
840
|
+
`2026-07-28` profile. `house git push` is the one local verb House does not list;
|
|
841
|
+
[working copies](#working-copies) describe it, and `house --help` and
|
|
842
|
+
`house git push --help` explain it.
|
|
833
843
|
|
|
834
844
|
Two verbs name files on the Session's own computer: `upload_attachment` takes
|
|
835
845
|
one `path`, and `append_record` takes `attachments` as local paths. The CLI
|
|
@@ -1248,14 +1258,54 @@ current grant. Every other file is read-only House content: the generated
|
|
|
1248
1258
|
grants anything, and House refuses a change to a read-only path however it is
|
|
1249
1259
|
committed.
|
|
1250
1260
|
|
|
1251
|
-
Local edits, staging and commits change nothing in House
|
|
1252
|
-
records the House content last received
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1261
|
+
Local edits, staging and commits change nothing in House until a commit is
|
|
1262
|
+
pushed. `refs/house/received` records the House content last received; each
|
|
1263
|
+
commit Kit wrote there, and each commit it acknowledged for a push, is a House
|
|
1264
|
+
state. The House revision of every file content Kit received, keyed by path and
|
|
1265
|
+
Git blob, stays in Kit home under `state/working-copies/`, outside the
|
|
1266
|
+
repository. A local commit is a local tree, never a House revision.
|
|
1267
|
+
|
|
1268
|
+
`house git push [commit]`, run anywhere inside a copy or one of its linked
|
|
1269
|
+
worktrees, submits one commit, `HEAD` by default, resolved once. Kit compares
|
|
1270
|
+
that commit's tree with the newest House state in its history, so every linked
|
|
1271
|
+
worktree keeps its own base, and sends the difference as one ordinary House edit:
|
|
1272
|
+
a changed field, section or preamble against its own prior value, any other
|
|
1273
|
+
change as the whole file against its exact revision, a new file as a create and
|
|
1274
|
+
a committed deletion as a removal. Staged and unstaged work, and files absent
|
|
1275
|
+
only from the working tree, are never sent. Native `git push` reaches no House
|
|
1276
|
+
remote. Inside an Agent Session the push goes through that Session's bridge,
|
|
1277
|
+
under its own authority and provenance; outside one it needs
|
|
1278
|
+
`house git push [commit] --owner`, which the resident Kit carries out under its
|
|
1279
|
+
control lease when a resident runs, and which otherwise acquires and releases
|
|
1280
|
+
its own. A Session refuses `--owner`, and a missing bridge is never replaced by
|
|
1281
|
+
owner authority.
|
|
1282
|
+
|
|
1283
|
+
House decides before any local Git step. A refusal changes nothing in House;
|
|
1284
|
+
after `edit_conflict` Kit records the current content House names for the
|
|
1285
|
+
refused paths in `refs/house/received`, reading by revision any content the
|
|
1286
|
+
refusal does not carry, so the agent merges or rebases onto it with native Git,
|
|
1287
|
+
commits, and pushes again. After acceptance Kit records House's result for the
|
|
1288
|
+
pushed paths, the contents an owner push receives or each accepted revision
|
|
1289
|
+
read by name, in a commit that covers the pushed one and in
|
|
1290
|
+
`refs/house/received`; a read-only path House regenerates, such as the Wiki
|
|
1291
|
+
index, stays out of Git. Kit then
|
|
1292
|
+
fast-forwards a clean branch still at the pushed commit, or rebases only its
|
|
1293
|
+
later commits. A result not yet readable, uncommitted tracked changes,
|
|
1294
|
+
a moved branch or a rebase conflict leave the branch as it was, with no stash or
|
|
1295
|
+
reset, and the push answers that House accepted it while local integration is
|
|
1296
|
+
pending; the next `house git push` finishes that integration without another
|
|
1297
|
+
House edit. A push whose answer was lost is recorded in Kit home before it is
|
|
1298
|
+
sent, and the next push by the same Session, or by the owner for an owner push,
|
|
1299
|
+
resends it under its original operation. Another caller cannot
|
|
1300
|
+
recover it: while the pushing Session lives it is refused as
|
|
1301
|
+
`kit_working_copy_push_pending`, and once that Session has ended Kit reads House
|
|
1302
|
+
again and drops it. When a retained push's operation has expired, Kit reads the
|
|
1303
|
+
Room again: if House holds the pushed content the push finishes as accepted,
|
|
1304
|
+
otherwise it is dropped and the next push submits it as a new operation.
|
|
1256
1305
|
|
|
1257
1306
|
Deselecting a Room, or losing read access to it, marks its copy inactive:
|
|
1258
|
-
launches omit it
|
|
1307
|
+
launches omit it, a new push is refused as `kit_working_copy_inactive`, and
|
|
1308
|
+
nothing local is deleted; a push already sent still settles and integrates. Selecting it again reads the Room
|
|
1259
1309
|
again, advances `refs/house/received`, and replaces only the read-only content
|
|
1260
1310
|
Kit wrote, never the branch, the index or a tracked file. A Room the owner may
|
|
1261
1311
|
read without sync is refused as `operation_denied` and stays uncopied, while its
|
package/bin/connect-linux.sh
CHANGED
|
@@ -9,6 +9,7 @@ MANUAL=0
|
|
|
9
9
|
WORKSPACE_SELECTED=0
|
|
10
10
|
HOUSE_SELECTED=0
|
|
11
11
|
FORWARDING=0
|
|
12
|
+
FORWARDED=kit
|
|
12
13
|
FORWARD=()
|
|
13
14
|
|
|
14
15
|
refuse() {
|
|
@@ -41,8 +42,9 @@ start_resident() {
|
|
|
41
42
|
docker run -d --name "$NAME" --restart unless-stopped --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --label "agentshouse.house=$HOUSE" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 "$IMAGE" resident --house "$HOUSE" >/dev/null
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
if [[ "${1:-}" == kit ]]; then
|
|
45
|
+
if [[ "${1:-}" == kit || "${1:-}" == house ]]; then
|
|
45
46
|
FORWARDING=1
|
|
47
|
+
FORWARDED="$1"
|
|
46
48
|
FORWARD=("${@:2}")
|
|
47
49
|
set --
|
|
48
50
|
fi
|
|
@@ -129,10 +131,18 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
|
|
|
129
131
|
[[ "$(docker inspect --format '{{index .Config.Labels "agentshouse.house"}}' "$NAME")" == "$HOUSE" ]] || refuse 'the installed Kit uses another House origin'
|
|
130
132
|
[[ -f "$KIT_HOME/credential" && -f "$KIT_HOME/environment" && -f "$KIT_HOME/house" ]] || refuse 'the installed Kit has no enrolled authority'
|
|
131
133
|
if ((FORWARDING)); then
|
|
132
|
-
if [[ "${FORWARD[0]:-}" == login ]]; then
|
|
134
|
+
if [[ "$FORWARDED" == kit && "${FORWARD[0]:-}" == login ]]; then
|
|
133
135
|
login_kit "${FORWARD[@]:1}"
|
|
134
136
|
exit 0
|
|
135
137
|
fi
|
|
138
|
+
PLACED=()
|
|
139
|
+
ENTRY=()
|
|
140
|
+
if [[ "$FORWARDED" == house ]]; then
|
|
141
|
+
HERE=$(pwd -P)
|
|
142
|
+
[[ "$HERE" == "$WORKSPACE" || "$HERE" == "$WORKSPACE"/* ]] || refuse "the current directory is outside the workspace $WORKSPACE"
|
|
143
|
+
PLACED=(--workdir "/agents/house${HERE#"$WORKSPACE"}")
|
|
144
|
+
ENTRY=(--entrypoint house)
|
|
145
|
+
fi
|
|
136
146
|
TERMINAL=()
|
|
137
147
|
if [[ -t 0 && -t 1 ]]; then TERMINAL=(-t); fi
|
|
138
148
|
if [[ -t 0 && ! -t 1 ]]; then
|
|
@@ -141,9 +151,9 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
|
|
|
141
151
|
stty -echo
|
|
142
152
|
fi
|
|
143
153
|
if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
|
|
144
|
-
docker exec -i "${TERMINAL[@]}" "$NAME"
|
|
154
|
+
docker exec -i "${TERMINAL[@]}" "${PLACED[@]}" "$NAME" "$FORWARDED" "${FORWARD[@]}"
|
|
145
155
|
else
|
|
146
|
-
"${DOCKER_RUN[@]:0:2}" -i "${TERMINAL[@]}" "${DOCKER_RUN[@]:2}" "${FORWARD[@]}"
|
|
156
|
+
"${DOCKER_RUN[@]:0:2}" -i "${TERMINAL[@]}" "${PLACED[@]}" "${ENTRY[@]}" "${DOCKER_RUN[@]:2}" "${FORWARD[@]}"
|
|
147
157
|
fi
|
|
148
158
|
exit
|
|
149
159
|
fi
|
package/bootstrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.18",
|
|
4
4
|
"hosts": {
|
|
5
5
|
"linux": {
|
|
6
|
-
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
6
|
+
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.18/connect-linux.sh)",
|
|
7
7
|
"asset": {
|
|
8
8
|
"name": "connect-linux.sh",
|
|
9
|
-
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
9
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.18/connect-linux.sh"
|
|
10
10
|
},
|
|
11
11
|
"workspace": {
|
|
12
12
|
"argument": "--workspace",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentshouse/kit",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.18",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"private": false,
|
|
10
10
|
"type": "module",
|
|
11
11
|
"bin": {
|
|
12
|
+
"house": "src/session-bin/house",
|
|
12
13
|
"kit": "bin/kit.js"
|
|
13
14
|
},
|
|
14
15
|
"exports": {
|
package/src/bridge.js
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { operationId } from "./operation.js";
|
|
19
19
|
import { houseFailed } from "./refusal.js";
|
|
20
20
|
|
|
21
|
-
function bridgedFrame(request, operation) {
|
|
21
|
+
export function bridgedFrame(request, operation) {
|
|
22
22
|
if (typeof request.method !== "string") return request;
|
|
23
23
|
const params = typeof request.params === "object" && request.params !== null ? request.params : {};
|
|
24
24
|
const declared = typeof params._meta === "object" && params._meta !== null ? params._meta : {};
|
package/src/contract.js
CHANGED
|
@@ -292,17 +292,21 @@ export const KIT_DOOR_DISCOVER_PATH = "/kit/door/discover";
|
|
|
292
292
|
export const KIT_DOOR_BOOTSTRAP_PATH = "/kit/door/bootstrap";
|
|
293
293
|
export const KIT_DOOR_ENUMERATE_PATH = "/kit/door/enumerate";
|
|
294
294
|
export const KIT_DOOR_BATCH_PATH = "/kit/door/batch";
|
|
295
|
+
export const KIT_DOOR_REVISIONS_PATH = "/kit/door/revisions";
|
|
295
296
|
export const EXPLICIT_SYNC_VERSION = "explicit-sync/2";
|
|
296
297
|
export const KIT_DOOR_UNMOUNT_PATH = "/kit/door/unmount";
|
|
297
298
|
|
|
298
299
|
export const KIT_WORKING_COPY_SELECTION_PATH = "/kit/working-copy/selection";
|
|
299
|
-
export const WORKING_COPY_VERSION = "working-copy-state/
|
|
300
|
+
export const WORKING_COPY_VERSION = "working-copy-state/2";
|
|
300
301
|
export const WORKING_COPIES_HOME = "working-copies";
|
|
301
302
|
export const WORKING_COPY_ROOM_HOMES = ["capture", "library", "collaboration"];
|
|
302
303
|
export const WORKING_COPY_PRIVATE_HOMES = ["library", "collaboration", "rooms", "agents", "house"];
|
|
303
304
|
export const WORKING_COPY_RECEIVED_REF = "refs/house/received";
|
|
304
305
|
export const WORKING_COPY_BRANCH = "main";
|
|
305
306
|
export const ROOM_REFERENCE = /^r_[0-9a-z]+\.[0-9a-z]$/;
|
|
307
|
+
export const WORKING_COPY_PUSH_METHOD = "house/git/push";
|
|
308
|
+
export const WORKING_COPY_PUSH_HELP =
|
|
309
|
+
"Submit a Room commit to House; default HEAD. Use --owner only outside an Agent Session. Native git push is not a House remote.";
|
|
306
310
|
|
|
307
311
|
export const MIGRATOR_CONTRACT = "kit-migrator/1";
|
|
308
312
|
export const MIGRATOR_CATEGORY = "migrator";
|
package/src/house-main.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
BRIDGE_NONCE_ENVIRONMENT,
|
|
5
|
+
BRIDGE_SOCKET_ENVIRONMENT,
|
|
6
|
+
WORKING_COPY_PUSH_HELP,
|
|
7
|
+
WORKING_COPY_PUSH_METHOD,
|
|
8
|
+
} from "./contract.js";
|
|
9
|
+
import { storedHouse } from "./login.js";
|
|
10
|
+
import { answerText, bridgeCall } from "./mcp.js";
|
|
11
|
+
import { formatCommandError, KitRefusal } from "./refusal.js";
|
|
12
|
+
import { ownerPush } from "./working-copy.js";
|
|
5
13
|
|
|
6
14
|
const HELP = ["--help", "-h"];
|
|
15
|
+
const OWNER = "--owner";
|
|
16
|
+
const GIT_PUSH_USAGE = `usage: house git push [commit] [${OWNER}]`;
|
|
7
17
|
const usage = (verb) => `usage: house ${verb} ['<arguments as one JSON object>' | - for standard input]`;
|
|
8
18
|
|
|
9
19
|
async function answered(method, params) {
|
|
@@ -49,10 +59,59 @@ function located(verb, args) {
|
|
|
49
59
|
return args;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
function printed(result) {
|
|
63
|
+
const text = answerText(result);
|
|
64
|
+
return result?.isError === true ? { err: text, status: 1 } : { out: text };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function ownerPushed(request) {
|
|
68
|
+
const directory = process.env.HOUSE_KIT_HOME ?? join(homedir(), ".house-kit");
|
|
69
|
+
const cancellation = new AbortController();
|
|
70
|
+
const cancel = () => cancellation.abort();
|
|
71
|
+
process.once("SIGINT", cancel);
|
|
72
|
+
process.once("SIGTERM", cancel);
|
|
73
|
+
try {
|
|
74
|
+
const pushed = await ownerPush(
|
|
75
|
+
{ house: await storedHouse(directory), directory, signal: cancellation.signal },
|
|
76
|
+
request,
|
|
77
|
+
);
|
|
78
|
+
return pushed.refused ? { err: pushed.text, status: 1 } : { out: pushed.text };
|
|
79
|
+
} catch (refusal) {
|
|
80
|
+
return { err: formatCommandError(refusal), status: 1 };
|
|
81
|
+
} finally {
|
|
82
|
+
process.removeListener("SIGINT", cancel);
|
|
83
|
+
process.removeListener("SIGTERM", cancel);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function gitPush([action, ...rest]) {
|
|
88
|
+
if (action !== "push") throw new KitRefusal("kit_house_arguments_invalid", GIT_PUSH_USAGE);
|
|
89
|
+
if (rest.some((argument) => HELP.includes(argument))) return { out: `${GIT_PUSH_USAGE}\n\n${WORKING_COPY_PUSH_HELP}` };
|
|
90
|
+
const owner = rest.includes(OWNER);
|
|
91
|
+
const named = rest.filter((argument) => argument !== OWNER);
|
|
92
|
+
if (named.length > 1 || named.some((argument) => argument.startsWith("-"))) {
|
|
93
|
+
throw new KitRefusal("kit_house_arguments_invalid", GIT_PUSH_USAGE);
|
|
94
|
+
}
|
|
95
|
+
const request = { cwd: process.cwd(), commit: named[0] ?? "HEAD" };
|
|
96
|
+
if (process.env[BRIDGE_SOCKET_ENVIRONMENT] === undefined) {
|
|
97
|
+
if (!owner) throw new KitRefusal("kit_bridge_unavailable");
|
|
98
|
+
return ownerPushed(request);
|
|
99
|
+
}
|
|
100
|
+
if (owner) throw new KitRefusal("kit_house_arguments_invalid", `${OWNER} is only for use outside an Agent Session`);
|
|
101
|
+
return printed(await answered(WORKING_COPY_PUSH_METHOD, request));
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
async function house([verb, argument, ...rest]) {
|
|
105
|
+
if (verb === "git") return gitPush([argument, ...rest].filter((part) => part !== undefined));
|
|
53
106
|
if (verb === undefined || HELP.includes(verb)) {
|
|
54
107
|
const tools = await listed();
|
|
55
|
-
return {
|
|
108
|
+
return {
|
|
109
|
+
out: [
|
|
110
|
+
usage("<verb>"),
|
|
111
|
+
...tools.map((tool) => `${tool.name}\n${tool.description}`),
|
|
112
|
+
`git push [commit]\n${WORKING_COPY_PUSH_HELP}`,
|
|
113
|
+
].join("\n\n"),
|
|
114
|
+
};
|
|
56
115
|
}
|
|
57
116
|
if (HELP.includes(argument)) {
|
|
58
117
|
const tool = (await listed()).find((one) => one.name === verb);
|
|
@@ -61,9 +120,7 @@ async function house([verb, argument, ...rest]) {
|
|
|
61
120
|
}
|
|
62
121
|
if (rest.length > 0) throw new KitRefusal("kit_house_arguments_invalid", usage(verb));
|
|
63
122
|
const args = located(verb, await argumentsOf(verb, argument));
|
|
64
|
-
|
|
65
|
-
const text = (result?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
66
|
-
return result?.isError === true ? { err: text, status: 1 } : { out: text };
|
|
123
|
+
return printed(await answered("tools/call", { name: verb, arguments: args }));
|
|
67
124
|
}
|
|
68
125
|
|
|
69
126
|
house(process.argv.slice(2)).then(
|
package/src/house.js
CHANGED
|
@@ -172,7 +172,7 @@ async function residentRelay(session, request, signal, draining) {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
export async function serveResident(session, directory, signal, drain) {
|
|
175
|
+
export async function serveResident(session, directory, signal, drain, push) {
|
|
176
176
|
const socket = residentSocket(directory);
|
|
177
177
|
await mkdir(dirname(socket), { recursive: true, mode: 0o700 });
|
|
178
178
|
await rm(socket, { force: true });
|
|
@@ -229,12 +229,15 @@ export async function serveResident(session, directory, signal, drain) {
|
|
|
229
229
|
}
|
|
230
230
|
const cancelled = new AbortController();
|
|
231
231
|
relayed.set(request.id, cancelled);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
232
|
+
const relaySignal = AbortSignal.any([signal, cancelled.signal]);
|
|
233
|
+
const answered =
|
|
234
|
+
request.push === undefined
|
|
235
|
+
? residentRelay(session, request, relaySignal, draining)
|
|
236
|
+
: push(request.push, relaySignal).then((pushed) => ({ pushed }));
|
|
237
|
+
void answered.then((answer) => {
|
|
238
|
+
relayed.delete(request.id);
|
|
239
|
+
if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
|
|
240
|
+
});
|
|
238
241
|
},
|
|
239
242
|
onRefusal: () => connection.destroy(),
|
|
240
243
|
});
|
|
@@ -302,45 +305,54 @@ async function residentDelegation(directory, fetcher) {
|
|
|
302
305
|
}
|
|
303
306
|
connection.unref();
|
|
304
307
|
let next = 0;
|
|
308
|
+
function exchange(frame, signal) {
|
|
309
|
+
signal?.throwIfAborted();
|
|
310
|
+
if (connection.destroyed) return Promise.resolve(unavailable());
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
const id = (next += 1);
|
|
313
|
+
const cancel = () => {
|
|
314
|
+
pending.delete(id);
|
|
315
|
+
settle(id);
|
|
316
|
+
if (!connection.destroyed) writeFrame(connection, { id, cancel: true });
|
|
317
|
+
reject(signal.reason);
|
|
318
|
+
};
|
|
319
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
320
|
+
pending.set(id, (answer) => {
|
|
321
|
+
signal?.removeEventListener("abort", cancel);
|
|
322
|
+
resolve(answer);
|
|
323
|
+
});
|
|
324
|
+
connection.ref();
|
|
325
|
+
writeFrame(connection, { id, ...frame });
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async function push(request, signal) {
|
|
329
|
+
const answer = await exchange({ push: request }, signal);
|
|
330
|
+
if (answer.pushed === undefined) throw new KitRefusal("kit_resident_unavailable");
|
|
331
|
+
return answer.pushed;
|
|
332
|
+
}
|
|
305
333
|
async function delegated(url, init = {}) {
|
|
306
334
|
const { authorization, ...headers } = init.headers ?? {};
|
|
307
335
|
if (authorization !== RESIDENT) return fetcher(url, init);
|
|
308
|
-
init.signal?.throwIfAborted();
|
|
309
336
|
const address = new URL(url);
|
|
310
|
-
const answer =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
resolve(answer);
|
|
324
|
-
});
|
|
325
|
-
connection.ref();
|
|
326
|
-
writeFrame(connection, {
|
|
327
|
-
id,
|
|
328
|
-
method: init.method ?? "GET",
|
|
329
|
-
path: `${address.pathname}${address.search}`,
|
|
330
|
-
headers,
|
|
331
|
-
...(typeof init.body === "string"
|
|
332
|
-
? { text: init.body }
|
|
333
|
-
: init.body === undefined
|
|
334
|
-
? {}
|
|
335
|
-
: { bytes: Buffer.from(init.body).toString("base64") }),
|
|
336
|
-
});
|
|
337
|
-
});
|
|
337
|
+
const answer = await exchange(
|
|
338
|
+
{
|
|
339
|
+
method: init.method ?? "GET",
|
|
340
|
+
path: `${address.pathname}${address.search}`,
|
|
341
|
+
headers,
|
|
342
|
+
...(typeof init.body === "string"
|
|
343
|
+
? { text: init.body }
|
|
344
|
+
: init.body === undefined
|
|
345
|
+
? {}
|
|
346
|
+
: { bytes: Buffer.from(init.body).toString("base64") }),
|
|
347
|
+
},
|
|
348
|
+
init.signal,
|
|
349
|
+
);
|
|
338
350
|
return new Response(answer.body === null ? null : Buffer.from(answer.body, "base64"), {
|
|
339
351
|
status: answer.status,
|
|
340
352
|
headers: answer.type === null ? {} : { "content-type": answer.type },
|
|
341
353
|
});
|
|
342
354
|
}
|
|
343
|
-
return { connection, fetch: delegated };
|
|
355
|
+
return { connection, fetch: delegated, push };
|
|
344
356
|
}
|
|
345
357
|
|
|
346
358
|
export async function openSession(options, dependencies = {}) {
|
|
@@ -359,6 +371,7 @@ export async function openSession(options, dependencies = {}) {
|
|
|
359
371
|
: await residentDelegation(options.directory, dependencies.fetch ?? fetch);
|
|
360
372
|
if (resident !== null) {
|
|
361
373
|
session.resident = resident.connection;
|
|
374
|
+
session.push = resident.push;
|
|
362
375
|
session.dependencies = { ...dependencies, fetch: resident.fetch };
|
|
363
376
|
return session;
|
|
364
377
|
}
|
package/src/local-files.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isAbsolute } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { stringify } from "yaml";
|
|
3
3
|
import { AGENT_OPERATION_META, ATTACHMENT_FILE_META } from "./contract.js";
|
|
4
|
+
import { answerText, answerYaml } from "./mcp.js";
|
|
4
5
|
import { derivedOperationId } from "./operation.js";
|
|
5
6
|
import { localAttachment, sendLocalConversationFile, uploadLocalAttachment } from "./original.js";
|
|
6
7
|
import { KitRefusal } from "./refusal.js";
|
|
@@ -12,18 +13,9 @@ class HouseRefused extends Error {
|
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function parsed(text) {
|
|
16
|
-
try {
|
|
17
|
-
return parse(text);
|
|
18
|
-
} catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
16
|
function answerOf(frame) {
|
|
24
17
|
if (frame.error !== undefined || frame.result?.isError === true) throw new HouseRefused(frame);
|
|
25
|
-
const
|
|
26
|
-
const answer = parsed(text);
|
|
18
|
+
const answer = answerYaml(answerText(frame.result));
|
|
27
19
|
if (answer === null || typeof answer !== "object") throw new KitRefusal("kit_house_answer_unreadable");
|
|
28
20
|
return answer;
|
|
29
21
|
}
|
package/src/mcp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parse } from "yaml";
|
|
2
3
|
import { bridgePeer } from "./peer.js";
|
|
3
4
|
import { KitRefusal } from "./refusal.js";
|
|
4
5
|
|
|
@@ -69,3 +70,15 @@ export async function bridgeCall(bridge, request, signal = undefined) {
|
|
|
69
70
|
await peer.end();
|
|
70
71
|
}
|
|
71
72
|
}
|
|
73
|
+
|
|
74
|
+
export function answerText(result) {
|
|
75
|
+
return (result?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function answerYaml(text) {
|
|
79
|
+
try {
|
|
80
|
+
return parse(text);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
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.js
CHANGED
|
@@ -41,7 +41,7 @@ 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
|
}
|
package/src/worker.js
CHANGED
|
@@ -10,15 +10,16 @@ import {
|
|
|
10
10
|
} from "./agents.js";
|
|
11
11
|
import { readBindings, removeBinding, writeBinding } from "./binding.js";
|
|
12
12
|
import { readContinuation, readContinuationRuns, writeContinuation, removeContinuation } from "./continuation.js";
|
|
13
|
-
import { openBridge } from "./bridge.js";
|
|
13
|
+
import { bridgedFrame, openBridge } from "./bridge.js";
|
|
14
14
|
import { assistantBlocks } from "./blocks.js";
|
|
15
15
|
import { retryConversationFiles } from "./conversation-originals.js";
|
|
16
16
|
import { openAssistantDrafts } from "./draft.js";
|
|
17
17
|
import { localRequest, reconcileInbound } from "./inbound.js";
|
|
18
18
|
import { localFiles } from "./local-files.js";
|
|
19
|
+
import { answerText, answerYaml } from "./mcp.js";
|
|
19
20
|
import { inheritedEnvironment } from "./command.js";
|
|
20
21
|
import { watchKitWork } from "./kit-work-stream.js";
|
|
21
|
-
import { applyWorkingCopies, workingCopyPaths } from "./working-copy.js";
|
|
22
|
+
import { applyWorkingCopies, ownerCaller, pushWorkingCopy, workingCopyPaths } from "./working-copy.js";
|
|
22
23
|
import { readConfiguration } from "./configuration.js";
|
|
23
24
|
import { launchBase, readyLaunchDirectory } from "./directory.js";
|
|
24
25
|
import {
|
|
@@ -32,8 +33,9 @@ import {
|
|
|
32
33
|
WORKER_HEARTBEAT_MS,
|
|
33
34
|
WORKER_INHERITED_ENVIRONMENT,
|
|
34
35
|
WORKER_SUPERVISION_POLL_MS,
|
|
36
|
+
WORKING_COPY_PUSH_METHOD,
|
|
35
37
|
} from "./contract.js";
|
|
36
|
-
import { EpochReportRequired, houseFailed, KitRefusal } from "./refusal.js";
|
|
38
|
+
import { EpochReportRequired, formatCommandError, houseFailed, KitRefusal } from "./refusal.js";
|
|
37
39
|
import {
|
|
38
40
|
acceptAssistantText,
|
|
39
41
|
acquireCommands,
|
|
@@ -270,6 +272,52 @@ function endOnEpochReport(ended, act) {
|
|
|
270
272
|
};
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
function refusedWith(text) {
|
|
276
|
+
const conflicts = answerYaml(text.slice(text.indexOf("\n") + 1))?.conflicts;
|
|
277
|
+
const current = Array.isArray(conflicts)
|
|
278
|
+
? conflicts.map(({ path, state, revision }) =>
|
|
279
|
+
state === "absent" ? { path, revision: null, content: null } : { path, revision },
|
|
280
|
+
)
|
|
281
|
+
: null;
|
|
282
|
+
return { refused: { code: text.split(":")[0].trim(), text, current } };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function acceptedWith(text) {
|
|
286
|
+
const written = answerYaml(text).results.map(({ path, op, revision }) =>
|
|
287
|
+
op === "remove" ? { path, revision: null, content: null } : { path, revision },
|
|
288
|
+
);
|
|
289
|
+
return { refused: null, written };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function sessionPush(context, bound, authority, frame, signal) {
|
|
293
|
+
const edit = async (body, sent) => {
|
|
294
|
+
const answer = await callHouseMcp(
|
|
295
|
+
context.session,
|
|
296
|
+
await sessionAuthority(context.session, bound.session, authority, sent),
|
|
297
|
+
bridgedFrame(
|
|
298
|
+
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "edit", arguments: { changes: body.changes } } },
|
|
299
|
+
body.operation_id,
|
|
300
|
+
),
|
|
301
|
+
sent,
|
|
302
|
+
);
|
|
303
|
+
if (answer.error !== undefined) return refusedWith(answer.error.message ?? "kit_house_refused");
|
|
304
|
+
const text = answerText(answer.result);
|
|
305
|
+
return answer.result?.isError === true ? refusedWith(text) : acceptedWith(text);
|
|
306
|
+
};
|
|
307
|
+
let outcome;
|
|
308
|
+
try {
|
|
309
|
+
outcome = await pushWorkingCopy(context, frame.params, { binding: { session: bound.session }, submit: edit }, signal);
|
|
310
|
+
} catch (refusal) {
|
|
311
|
+
if (!(refusal instanceof KitRefusal)) throw refusal;
|
|
312
|
+
outcome = { refused: true, text: refusal.message };
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
jsonrpc: "2.0",
|
|
316
|
+
id: frame.id,
|
|
317
|
+
result: { content: [{ type: "text", text: outcome.text }], ...(outcome.refused ? { isError: true } : {}) },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
273
321
|
async function openRunBridge(context, bound, authority, start) {
|
|
274
322
|
const ended = new AbortController();
|
|
275
323
|
const handed = (act) => endOnEpochReport(ended, act);
|
|
@@ -291,8 +339,9 @@ async function openRunBridge(context, bound, authority, start) {
|
|
|
291
339
|
),
|
|
292
340
|
secret: (interaction, ceremony) => awaitSecretInput(context.session, interaction, ceremony),
|
|
293
341
|
settle: handed((interaction, outcome) => settleInteraction(context, interaction, outcome)),
|
|
294
|
-
forward: handed((frame, signal) =>
|
|
295
|
-
|
|
342
|
+
forward: handed((frame, signal) => frame.method === WORKING_COPY_PUSH_METHOD
|
|
343
|
+
? sessionPush(context, bound, authority, frame, signal)
|
|
344
|
+
: localFiles(async (sent) =>
|
|
296
345
|
callHouseMcp(
|
|
297
346
|
context.session,
|
|
298
347
|
await sessionAuthority(context.session, bound.session, authority, signal),
|
|
@@ -960,7 +1009,16 @@ export async function runResident(options, dependencies = {}) {
|
|
|
960
1009
|
const wake = residentWake();
|
|
961
1010
|
const update = residentUpdate(context, wake);
|
|
962
1011
|
context.update = update;
|
|
963
|
-
const served = await serveResident(
|
|
1012
|
+
const served = await serveResident(
|
|
1013
|
+
session,
|
|
1014
|
+
context.directory,
|
|
1015
|
+
options.signal,
|
|
1016
|
+
(serving) => update.request(serving),
|
|
1017
|
+
(request, signal) =>
|
|
1018
|
+
pushWorkingCopy({ directory: context.directory, session }, request, ownerCaller(session), signal).catch(
|
|
1019
|
+
(error) => ({ refused: true, text: formatCommandError(error) }),
|
|
1020
|
+
),
|
|
1021
|
+
);
|
|
964
1022
|
const serve = async (signal) => {
|
|
965
1023
|
context.busy = true;
|
|
966
1024
|
const watcher = new AbortController();
|
package/src/working-copy.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { readBindings } from "./binding.js";
|
|
4
5
|
import {
|
|
6
|
+
KIT_DOOR_BATCH_PATH,
|
|
5
7
|
KIT_DOOR_DISCOVER_PATH,
|
|
8
|
+
KIT_DOOR_REVISIONS_PATH,
|
|
6
9
|
KIT_WORKING_COPY_SELECTION_PATH,
|
|
7
10
|
ROOM_REFERENCE,
|
|
8
11
|
WORKING_COPIES_HOME,
|
|
@@ -13,13 +16,16 @@ import {
|
|
|
13
16
|
WORKING_COPY_VERSION,
|
|
14
17
|
} from "./contract.js";
|
|
15
18
|
import { runGit } from "./git.js";
|
|
16
|
-
import {
|
|
19
|
+
import { operationId } from "./operation.js";
|
|
20
|
+
import { formatCommandError, houseAnswered, houseFailed, KitRefusal } from "./refusal.js";
|
|
17
21
|
import { readNote, writeNote, writeText } from "./state.js";
|
|
18
|
-
import { bootstrapBundle, discoveryOf, door } from "./sync.js";
|
|
19
|
-
import { sidecarPath } from "./sync-library.js";
|
|
22
|
+
import { bootstrapBundle, discoveryOf, door, withSession } from "./sync.js";
|
|
23
|
+
import { sidecarPath, structuredOrReplace } from "./sync-library.js";
|
|
20
24
|
import { locate, removeReplicaFile, writeReplicaFile } from "./sync-state.js";
|
|
21
25
|
|
|
22
|
-
const
|
|
26
|
+
const COMMITTER_NAME = "House Kit";
|
|
27
|
+
const COMMITTER_EMAIL = "house-kit@localhost";
|
|
28
|
+
const COMMITTER = `${COMMITTER_NAME} <${COMMITTER_EMAIL}>`;
|
|
23
29
|
const GIT_FAILED = "kit_working_copy_git_failed";
|
|
24
30
|
|
|
25
31
|
function rejected(detail = null) {
|
|
@@ -149,25 +155,53 @@ function quotedPath(path) {
|
|
|
149
155
|
return `"${path.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\n")}"`;
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const lines = [
|
|
155
|
-
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
|
|
158
|
+
function importCommit(message, parents) {
|
|
159
|
+
return [
|
|
156
160
|
Buffer.from(`committer ${COMMITTER} ${Math.floor(Date.now() / 1000)} +0000\n`),
|
|
157
161
|
...importData(message),
|
|
158
|
-
...(parent ===
|
|
159
|
-
Buffer.from("deleteall\n"),
|
|
162
|
+
...parents.map((parent, index) => Buffer.from(`${index === 0 ? "from" : "merge"} ${parent}\n`)),
|
|
160
163
|
];
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function importFile(path, content) {
|
|
167
|
+
if (content === null) return [Buffer.from(`D ${quotedPath(path)}\n`)];
|
|
168
|
+
return [Buffer.from(`M 100644 inline ${quotedPath(path)}\n`), ...importData(content)];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function importReceived(repository, { message, parents, files, whole = false, acknowledging = null }, signal) {
|
|
172
|
+
const stream = [];
|
|
173
|
+
const merged = [...parents];
|
|
174
|
+
if (acknowledging !== null && acknowledging.edits.length === 0) merged.push(acknowledging.commit);
|
|
175
|
+
if (acknowledging !== null && acknowledging.edits.length > 0) {
|
|
176
|
+
stream.push(
|
|
177
|
+
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\nmark :1\n`),
|
|
178
|
+
...importCommit(`House accepted ${acknowledging.commit}`, [acknowledging.commit]),
|
|
179
|
+
...acknowledging.edits.flatMap((edit) => importFile(edit.path, edit.content)),
|
|
180
|
+
Buffer.from("\n"),
|
|
181
|
+
);
|
|
182
|
+
merged.push(":1");
|
|
163
183
|
}
|
|
164
|
-
|
|
184
|
+
stream.push(
|
|
185
|
+
Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
|
|
186
|
+
...importCommit(message, merged),
|
|
187
|
+
...(whole ? [Buffer.from("deleteall\n")] : []),
|
|
188
|
+
...files.flatMap((file) => importFile(file.path, file.content)),
|
|
189
|
+
Buffer.from("\n"),
|
|
190
|
+
);
|
|
165
191
|
await runGit(repository, ["fast-import", "--quiet", "--force"], {
|
|
166
|
-
input: Buffer.concat(
|
|
192
|
+
input: Buffer.concat(stream),
|
|
167
193
|
signal,
|
|
168
194
|
failure: GIT_FAILED,
|
|
169
195
|
});
|
|
170
|
-
const
|
|
196
|
+
const received = await commitOf(repository, WORKING_COPY_RECEIVED_REF, signal);
|
|
197
|
+
if (acknowledging === null) return { received, acknowledged: null };
|
|
198
|
+
const acknowledged =
|
|
199
|
+
acknowledging.edits.length === 0 ? acknowledging.commit : await commitOf(repository, `${received}^2`, signal);
|
|
200
|
+
return { received, acknowledged };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function commitOf(repository, name, signal) {
|
|
204
|
+
const commit = await runGit(repository, ["rev-parse", "--verify", `${name}^{commit}`], {
|
|
171
205
|
capture: true,
|
|
172
206
|
signal,
|
|
173
207
|
failure: GIT_FAILED,
|
|
@@ -175,6 +209,16 @@ async function importReceived(repository, scope, read, parent, signal) {
|
|
|
175
209
|
return commit.toString("utf8").trim();
|
|
176
210
|
}
|
|
177
211
|
|
|
212
|
+
async function remembered(repository, revisions, commit, files, signal) {
|
|
213
|
+
const tree = await treeOf(repository, commit, signal);
|
|
214
|
+
const next = { ...revisions };
|
|
215
|
+
for (const { path, revision } of files) {
|
|
216
|
+
const entry = tree.get(path);
|
|
217
|
+
if (entry !== undefined && revision !== null) next[path] = { ...next[path], [entry.object]: revision };
|
|
218
|
+
}
|
|
219
|
+
return next;
|
|
220
|
+
}
|
|
221
|
+
|
|
178
222
|
function readOnlyContents(read) {
|
|
179
223
|
const contents = new Map();
|
|
180
224
|
for (const file of read.files) {
|
|
@@ -268,13 +312,21 @@ function manifest(read, commit) {
|
|
|
268
312
|
commit,
|
|
269
313
|
position: read.position,
|
|
270
314
|
log_epoch: read.logEpoch,
|
|
271
|
-
files: read.files.map(({ path, revision, sha256, writable }) => ({ path, revision, sha256, writable })),
|
|
272
315
|
read_only: [...readOnlyContents(read).keys()],
|
|
273
316
|
};
|
|
274
317
|
}
|
|
275
318
|
|
|
319
|
+
function copyPath(directory, copy) {
|
|
320
|
+
return statePath(directory, { kind: copy.scope, ref: copy.room_ref });
|
|
321
|
+
}
|
|
322
|
+
|
|
276
323
|
async function writeCopy(directory, copy, signal) {
|
|
277
|
-
await writeNote(
|
|
324
|
+
await writeNote(copyPath(directory, copy), copy, signal);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readCopy(path) {
|
|
328
|
+
const copy = await readNote(path, null);
|
|
329
|
+
return copy?.version === WORKING_COPY_VERSION ? copy : null;
|
|
278
330
|
}
|
|
279
331
|
|
|
280
332
|
async function heldCopies(directory) {
|
|
@@ -287,10 +339,30 @@ async function heldCopies(directory) {
|
|
|
287
339
|
}
|
|
288
340
|
const held = [];
|
|
289
341
|
for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) {
|
|
290
|
-
held.push(await
|
|
342
|
+
held.push(await readCopy(join(rooms, name)));
|
|
291
343
|
}
|
|
292
|
-
held.push(await
|
|
293
|
-
return held.filter((copy) => copy
|
|
344
|
+
held.push(await readCopy(statePath(directory, { kind: "private" })));
|
|
345
|
+
return held.filter((copy) => copy !== null);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const turns = new Map();
|
|
349
|
+
|
|
350
|
+
function exclusively(path, work) {
|
|
351
|
+
const turn = (turns.get(path) ?? Promise.resolve()).then(work);
|
|
352
|
+
const settled = turn.then(
|
|
353
|
+
() => undefined,
|
|
354
|
+
() => undefined,
|
|
355
|
+
);
|
|
356
|
+
turns.set(path, settled);
|
|
357
|
+
void settled.then(() => {
|
|
358
|
+
if (turns.get(path) === settled) turns.delete(path);
|
|
359
|
+
});
|
|
360
|
+
return turn;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function received(scope, read, parents) {
|
|
364
|
+
const files = read.files.filter((file) => file.writable);
|
|
365
|
+
return { message: `House ${housePath(scope)} at position ${read.position}`, parents, files, whole: true };
|
|
294
366
|
}
|
|
295
367
|
|
|
296
368
|
async function materialize(context, scope, signal) {
|
|
@@ -300,10 +372,13 @@ async function materialize(context, scope, signal) {
|
|
|
300
372
|
if (await present(repository)) throw new KitRefusal("kit_working_copy_unmanaged", repository);
|
|
301
373
|
const staging = join(dirname(repository), `.${randomUUID()}.staging`);
|
|
302
374
|
await mkdir(staging, { recursive: true });
|
|
375
|
+
const imported = received(scope, read, []);
|
|
303
376
|
let commit;
|
|
377
|
+
let revisions;
|
|
304
378
|
try {
|
|
305
379
|
await runGit(staging, ["init", "-q", "-b", WORKING_COPY_BRANCH], { signal, failure: GIT_FAILED });
|
|
306
|
-
commit = await importReceived(staging,
|
|
380
|
+
commit = (await importReceived(staging, imported, signal)).received;
|
|
381
|
+
revisions = await remembered(staging, {}, commit, imported.files, signal);
|
|
307
382
|
await runGit(staging, ["update-ref", `refs/heads/${WORKING_COPY_BRANCH}`, commit], {
|
|
308
383
|
signal,
|
|
309
384
|
failure: GIT_FAILED,
|
|
@@ -316,7 +391,6 @@ async function materialize(context, scope, signal) {
|
|
|
316
391
|
await rm(staging, { recursive: true, force: true });
|
|
317
392
|
throw error;
|
|
318
393
|
}
|
|
319
|
-
const received = manifest(read, commit);
|
|
320
394
|
await writeCopy(context.directory, {
|
|
321
395
|
version: WORKING_COPY_VERSION,
|
|
322
396
|
scope: scope.kind,
|
|
@@ -324,27 +398,33 @@ async function materialize(context, scope, signal) {
|
|
|
324
398
|
room_handle: scope.handle,
|
|
325
399
|
repository,
|
|
326
400
|
active: true,
|
|
327
|
-
|
|
328
|
-
|
|
401
|
+
received: manifest(read, commit),
|
|
402
|
+
revisions,
|
|
329
403
|
});
|
|
330
404
|
}
|
|
331
405
|
|
|
406
|
+
async function receive(context, copy, scope, signal) {
|
|
407
|
+
const read = await readScope(context.session, scope, signal);
|
|
408
|
+
if (read.anchor !== copy.room_ref) throw rejected("bootstrap");
|
|
409
|
+
const imported = received(scope, read, [copy.received.commit]);
|
|
410
|
+
const commit = (await importReceived(copy.repository, imported, signal)).received;
|
|
411
|
+
await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
|
|
412
|
+
await writeIgnoreRules(copy.repository, read, signal);
|
|
413
|
+
return {
|
|
414
|
+
...copy,
|
|
415
|
+
received: manifest(read, commit),
|
|
416
|
+
revisions: await remembered(copy.repository, copy.revisions, commit, imported.files, signal),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
332
420
|
async function refresh(context, copy, scope, signal) {
|
|
333
421
|
if (!(await present(copy.repository))) {
|
|
334
|
-
await rm(
|
|
422
|
+
await rm(copyPath(context.directory, copy), { force: true });
|
|
335
423
|
await materialize(context, scope, signal);
|
|
336
424
|
return;
|
|
337
425
|
}
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
const commit = await importReceived(copy.repository, scope, read, copy.received.commit, signal);
|
|
341
|
-
await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
|
|
342
|
-
await writeIgnoreRules(copy.repository, read, signal);
|
|
343
|
-
await writeCopy(
|
|
344
|
-
context.directory,
|
|
345
|
-
{ ...copy, room_handle: scope.handle, active: true, received: manifest(read, commit) },
|
|
346
|
-
signal,
|
|
347
|
-
);
|
|
426
|
+
const next = await receive(context, copy, scope, signal);
|
|
427
|
+
await writeCopy(context.directory, { ...next, room_handle: scope.handle, active: true }, signal);
|
|
348
428
|
}
|
|
349
429
|
|
|
350
430
|
function scopeRefused(error) {
|
|
@@ -355,21 +435,26 @@ export async function applyWorkingCopies(context, signal) {
|
|
|
355
435
|
const selected = (await savedSelection(context.session, signal)).filter(
|
|
356
436
|
(scope) => scope.kind === "private" || scope.handle !== null,
|
|
357
437
|
);
|
|
358
|
-
const held
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
438
|
+
for (const held of await heldCopies(context.directory)) {
|
|
439
|
+
if (!held.active || selected.some((scope) => sameScope(held, scope))) continue;
|
|
440
|
+
const path = copyPath(context.directory, held);
|
|
441
|
+
await exclusively(path, async () => {
|
|
442
|
+
const copy = await readCopy(path);
|
|
443
|
+
if (copy !== null) await writeCopy(context.directory, { ...copy, active: false }, signal);
|
|
444
|
+
});
|
|
363
445
|
}
|
|
364
446
|
const refused = [];
|
|
365
447
|
for (const scope of selected) {
|
|
366
|
-
const
|
|
448
|
+
const path = statePath(context.directory, scope);
|
|
367
449
|
try {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
await
|
|
372
|
-
|
|
450
|
+
await exclusively(path, async () => {
|
|
451
|
+
const copy = await readCopy(path);
|
|
452
|
+
if (copy === null) await materialize(context, scope, signal);
|
|
453
|
+
else if (!copy.active) await refresh(context, copy, scope, signal);
|
|
454
|
+
else if (copy.room_handle !== scope.handle) {
|
|
455
|
+
await writeCopy(context.directory, { ...copy, room_handle: scope.handle }, signal);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
373
458
|
} catch (error) {
|
|
374
459
|
if (!scopeRefused(error)) throw error;
|
|
375
460
|
refused.push({ path: housePath(scope), code: error.code });
|
|
@@ -387,3 +472,387 @@ export async function workingCopyPaths(directory) {
|
|
|
387
472
|
}))
|
|
388
473
|
.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
389
474
|
}
|
|
475
|
+
|
|
476
|
+
function scopeOf(copy) {
|
|
477
|
+
return { kind: copy.scope, ref: copy.room_ref, handle: copy.room_handle };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function without(copy) {
|
|
481
|
+
const { push, ...settled } = copy;
|
|
482
|
+
return settled;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function lines(output) {
|
|
486
|
+
return output.toString("utf8").split("\n").filter((line) => line !== "");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function succeeded(git) {
|
|
490
|
+
return git.then(
|
|
491
|
+
() => true,
|
|
492
|
+
() => false,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function contains(repository, ancestor, descendant, signal) {
|
|
497
|
+
return succeeded(
|
|
498
|
+
runGit(repository, ["merge-base", "--is-ancestor", ancestor, descendant], { signal, failure: GIT_FAILED }),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function located(cwd, signal) {
|
|
503
|
+
const [common, worktree] = lines(
|
|
504
|
+
await runGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel"], {
|
|
505
|
+
capture: true,
|
|
506
|
+
signal,
|
|
507
|
+
failure: "kit_working_copy_unresolved",
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
return { repository: await realpath(dirname(common)), worktree };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function copyAt(directory, repository) {
|
|
514
|
+
for (const copy of await heldCopies(directory)) {
|
|
515
|
+
if ((await realpath(copy.repository).catch(() => null)) === repository) return copy;
|
|
516
|
+
}
|
|
517
|
+
throw new KitRefusal("kit_working_copy_not_found", repository);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function treeOf(repository, commit, signal) {
|
|
521
|
+
const listed = await runGit(repository, ["ls-tree", "-r", "-z", commit], {
|
|
522
|
+
capture: true,
|
|
523
|
+
signal,
|
|
524
|
+
failure: GIT_FAILED,
|
|
525
|
+
});
|
|
526
|
+
const tree = new Map();
|
|
527
|
+
for (const entry of listed.toString("utf8").split("\0").filter((line) => line !== "")) {
|
|
528
|
+
const tab = entry.indexOf("\t");
|
|
529
|
+
const [, type, object] = entry.slice(0, tab).split(" ");
|
|
530
|
+
tree.set(entry.slice(tab + 1), { type, object });
|
|
531
|
+
}
|
|
532
|
+
return tree;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function blobText(repository, entry, path, signal) {
|
|
536
|
+
if (entry === undefined) return null;
|
|
537
|
+
if (entry.type !== "blob") throw new KitRefusal("kit_working_copy_not_text", path);
|
|
538
|
+
const bytes = await runGit(repository, ["cat-file", "blob", entry.object], {
|
|
539
|
+
capture: true,
|
|
540
|
+
signal,
|
|
541
|
+
failure: GIT_FAILED,
|
|
542
|
+
});
|
|
543
|
+
try {
|
|
544
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
545
|
+
} catch {
|
|
546
|
+
throw new KitRefusal("kit_working_copy_not_text", path);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function trustedBase(copy, commit, signal) {
|
|
551
|
+
const git = (args) => runGit(copy.repository, args, { capture: true, signal, failure: GIT_FAILED });
|
|
552
|
+
const house = new Set(
|
|
553
|
+
lines(await git(["rev-list", "--first-parent", "--parents", copy.received.commit])).flatMap((line) => {
|
|
554
|
+
const [received, , acknowledged] = line.split(" ");
|
|
555
|
+
return acknowledged === undefined ? [received] : [received, acknowledged];
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
const base = lines(await git(["rev-list", "--topo-order", commit])).find((ancestor) => house.has(ancestor));
|
|
559
|
+
if (base !== undefined) return base;
|
|
560
|
+
throw new KitRefusal(
|
|
561
|
+
"kit_working_copy_base_unknown",
|
|
562
|
+
`${commit} contains no House state; merge or rebase onto ${WORKING_COPY_RECEIVED_REF}`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function derived(copy, base, commit, signal) {
|
|
567
|
+
const root = housePath(scopeOf(copy));
|
|
568
|
+
const before = await treeOf(copy.repository, base, signal);
|
|
569
|
+
const after = await treeOf(copy.repository, commit, signal);
|
|
570
|
+
const changes = [];
|
|
571
|
+
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
572
|
+
const prior = before.get(path);
|
|
573
|
+
const next = after.get(path);
|
|
574
|
+
if (prior?.object === next?.object) continue;
|
|
575
|
+
const target = `${root}/${path}`;
|
|
576
|
+
const revision = copy.revisions[path]?.[prior?.object];
|
|
577
|
+
if (next === undefined) {
|
|
578
|
+
changes.push({ op: "remove", path: target, base: revision });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const content = await blobText(copy.repository, next, path, signal);
|
|
582
|
+
if (prior === undefined) {
|
|
583
|
+
changes.push({ op: "create", path: target, content });
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const recorded = { content: await blobText(copy.repository, prior, path, signal), revision };
|
|
587
|
+
changes.push(...structuredOrReplace(root, path, recorded, content));
|
|
588
|
+
}
|
|
589
|
+
return changes;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function revisionText(session, { path, revision }, signal) {
|
|
593
|
+
const page = await door(session, KIT_DOOR_REVISIONS_PATH, { path, revision }, signal);
|
|
594
|
+
const content = page?.revisions?.find((entry) => entry?.revision === revision)?.content;
|
|
595
|
+
if (typeof content !== "string") throw new KitRefusal("kit_working_copy_result_unreadable", `${path} at ${revision}`);
|
|
596
|
+
return content;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function materials(context, copy, entries, signal) {
|
|
600
|
+
const root = housePath(scopeOf(copy));
|
|
601
|
+
const files = [];
|
|
602
|
+
for (const entry of entries) {
|
|
603
|
+
const content = entry.content === undefined ? await revisionText(context.session, entry, signal) : entry.content;
|
|
604
|
+
files.push({ path: entry.path.slice(root.length + 1), revision: entry.revision, content });
|
|
605
|
+
}
|
|
606
|
+
return files;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function recorded(copy, files, message, acknowledging, signal) {
|
|
610
|
+
const imported = await importReceived(
|
|
611
|
+
copy.repository,
|
|
612
|
+
{ message, parents: [copy.received.commit], files, acknowledging },
|
|
613
|
+
signal,
|
|
614
|
+
);
|
|
615
|
+
return {
|
|
616
|
+
copy: {
|
|
617
|
+
...copy,
|
|
618
|
+
received: { ...copy.received, commit: imported.received },
|
|
619
|
+
revisions: await remembered(copy.repository, copy.revisions, imported.received, files, signal),
|
|
620
|
+
},
|
|
621
|
+
acknowledged: imported.acknowledged,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function submittedPaths(copy) {
|
|
626
|
+
const root = housePath(scopeOf(copy));
|
|
627
|
+
return new Set(copy.push.changes.map((change) => change.path.slice(root.length + 1)));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function short(commit) {
|
|
631
|
+
return commit.slice(0, 12);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function acknowledge(context, copy, signal) {
|
|
635
|
+
const { push } = copy;
|
|
636
|
+
const files = await materials(context, copy, push.written, signal);
|
|
637
|
+
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
638
|
+
const edits = [];
|
|
639
|
+
for (const file of files) {
|
|
640
|
+
if ((await blobText(copy.repository, selected.get(file.path), file.path, signal)) !== file.content) edits.push(file);
|
|
641
|
+
}
|
|
642
|
+
const { copy: next, acknowledged } = await recorded(
|
|
643
|
+
copy,
|
|
644
|
+
files,
|
|
645
|
+
`House ${housePath(scopeOf(copy))} with ${push.commit} accepted`,
|
|
646
|
+
{ commit: push.commit, edits },
|
|
647
|
+
signal,
|
|
648
|
+
);
|
|
649
|
+
return { ...next, push: { ...push, acknowledged } };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function integrated(copy, signal) {
|
|
653
|
+
const { commit, acknowledged, worktree, branch } = copy.push;
|
|
654
|
+
const accepted = `House accepted ${short(commit)}`;
|
|
655
|
+
if (acknowledged === commit) return { done: true, text: `${accepted}.` };
|
|
656
|
+
const pending = (reason) => ({
|
|
657
|
+
done: false,
|
|
658
|
+
text: `${accepted}; local integration is pending (${reason}): ${acknowledged} holds House's result.`,
|
|
659
|
+
});
|
|
660
|
+
const git = (args) => runGit(worktree, args, { capture: true, signal, failure: GIT_FAILED });
|
|
661
|
+
const head = await git(["symbolic-ref", "-q", "HEAD"]).then(
|
|
662
|
+
(output) => lines(output)[0],
|
|
663
|
+
() => null,
|
|
664
|
+
);
|
|
665
|
+
if (branch === null || head !== branch) return pending("the branch changed");
|
|
666
|
+
const tip = lines(await git(["rev-parse", "HEAD"]))[0];
|
|
667
|
+
const name = branch.replace(/^refs\/heads\//, "");
|
|
668
|
+
if (await contains(worktree, acknowledged, tip, signal)) return { done: true, text: `${accepted}; ${name} holds it.` };
|
|
669
|
+
if (!(await contains(worktree, commit, tip, signal))) return pending("the branch changed");
|
|
670
|
+
if (lines(await git(["status", "--porcelain", "--untracked-files=no"])).length > 0) {
|
|
671
|
+
return pending("uncommitted changes");
|
|
672
|
+
}
|
|
673
|
+
if (tip === commit) {
|
|
674
|
+
if (!(await succeeded(git(["merge", "--ff-only", "-q", acknowledged])))) return pending("a conflict");
|
|
675
|
+
} else if (
|
|
676
|
+
!(await succeeded(
|
|
677
|
+
git(["-c", `user.name=${COMMITTER_NAME}`, "-c", `user.email=${COMMITTER_EMAIL}`, "rebase", "-q", "--onto", acknowledged, commit]),
|
|
678
|
+
))
|
|
679
|
+
) {
|
|
680
|
+
await git(["rebase", "--abort"]).catch(() => undefined);
|
|
681
|
+
return pending("a conflict");
|
|
682
|
+
}
|
|
683
|
+
return { done: true, text: `${accepted}; ${name} is at ${short(lines(await git(["rev-parse", "HEAD"]))[0])}.` };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function finish(context, copy, signal) {
|
|
687
|
+
try {
|
|
688
|
+
let acknowledged = copy;
|
|
689
|
+
if (copy.push.acknowledged === undefined) {
|
|
690
|
+
acknowledged = await acknowledge(context, copy, signal);
|
|
691
|
+
await writeCopy(context.directory, acknowledged, signal);
|
|
692
|
+
}
|
|
693
|
+
const outcome = await integrated(acknowledged, signal);
|
|
694
|
+
if (outcome.done) await writeCopy(context.directory, without(acknowledged), signal);
|
|
695
|
+
return { refused: false, text: outcome.text };
|
|
696
|
+
} catch (error) {
|
|
697
|
+
return {
|
|
698
|
+
refused: false,
|
|
699
|
+
text: `House accepted ${short(copy.push.commit)}; local integration is pending (${formatCommandError(error)}); run house git push again to finish it.`,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function sameCaller(left, right) {
|
|
705
|
+
return left.session === right.session && left.owner === right.owner;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function definite(error) {
|
|
709
|
+
return (
|
|
710
|
+
error instanceof KitRefusal &&
|
|
711
|
+
error.code !== "kit_command_cancelled" &&
|
|
712
|
+
!houseFailed(error) &&
|
|
713
|
+
!(error.status >= 500)
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function orphaned(context, copy, signal) {
|
|
718
|
+
const held = copy.push.caller.session;
|
|
719
|
+
const live = (binding) => binding.session === held && binding.stopped === null && binding.reported === null;
|
|
720
|
+
if (held === undefined || (await readBindings(context.directory)).some(live)) {
|
|
721
|
+
throw new KitRefusal("kit_working_copy_push_pending", "another caller's push awaits its recovery");
|
|
722
|
+
}
|
|
723
|
+
await writeCopy(context.directory, await receive(context, without(copy), scopeOf(copy), signal), signal);
|
|
724
|
+
throw new KitRefusal(
|
|
725
|
+
"kit_working_copy_push_unrecoverable",
|
|
726
|
+
`the Session that pushed ended before its outcome arrived; ${WORKING_COPY_RECEIVED_REF} holds House's current state`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function expired(context, copy, text, signal) {
|
|
731
|
+
const { push } = copy;
|
|
732
|
+
const root = housePath(scopeOf(copy));
|
|
733
|
+
const confirmed = await receive(context, without(copy), scopeOf(copy), signal);
|
|
734
|
+
const house = await treeOf(copy.repository, confirmed.received.commit, signal);
|
|
735
|
+
const selected = await treeOf(copy.repository, push.commit, signal);
|
|
736
|
+
const paths = [...submittedPaths(copy)];
|
|
737
|
+
if (paths.some((path) => house.get(path)?.object !== selected.get(path)?.object)) {
|
|
738
|
+
await writeCopy(context.directory, confirmed, signal);
|
|
739
|
+
return {
|
|
740
|
+
refused: true,
|
|
741
|
+
text: `${text}\nHouse does not hold ${short(push.commit)}; run house git push again to submit it as a new operation.`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
const written = [];
|
|
745
|
+
for (const path of paths) {
|
|
746
|
+
const entry = house.get(path);
|
|
747
|
+
written.push({
|
|
748
|
+
path: `${root}/${path}`,
|
|
749
|
+
revision: entry === undefined ? null : confirmed.revisions[path][entry.object],
|
|
750
|
+
content: await blobText(copy.repository, entry, path, signal),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
const accepted = { ...confirmed, push: { ...push, state: "accepted", written } };
|
|
754
|
+
await writeCopy(context.directory, accepted, signal);
|
|
755
|
+
return finish(context, accepted, signal);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function settle(context, copy, caller, signal) {
|
|
759
|
+
const { push } = copy;
|
|
760
|
+
if (!sameCaller(push.caller, caller.binding)) return orphaned(context, copy, signal);
|
|
761
|
+
let answer;
|
|
762
|
+
try {
|
|
763
|
+
answer = await caller.submit({ operation_id: push.operation_id, changes: push.changes }, signal);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (!definite(error)) {
|
|
766
|
+
throw new KitRefusal("kit_working_copy_push_uncertain", "run house git push again to recover its outcome");
|
|
767
|
+
}
|
|
768
|
+
const current = error.payload?.error?.current;
|
|
769
|
+
answer = { refused: { code: error.code, text: formatCommandError(error), current: Array.isArray(current) ? current : null } };
|
|
770
|
+
}
|
|
771
|
+
const { refused } = answer;
|
|
772
|
+
if (refused?.code === "operation_expired") return expired(context, copy, refused.text, signal);
|
|
773
|
+
if (refused === null) {
|
|
774
|
+
const submitted = new Set(push.changes.map((change) => change.path));
|
|
775
|
+
const written = answer.written.filter((entry) => submitted.has(entry.path));
|
|
776
|
+
const accepted = { ...copy, push: { ...push, state: "accepted", written } };
|
|
777
|
+
await writeCopy(context.directory, accepted, signal);
|
|
778
|
+
return finish(context, accepted, signal);
|
|
779
|
+
}
|
|
780
|
+
const settled = without(copy);
|
|
781
|
+
await writeCopy(context.directory, settled, signal);
|
|
782
|
+
if (refused.current === null) return { refused: true, text: refused.text };
|
|
783
|
+
try {
|
|
784
|
+
const files = await materials(context, settled, refused.current, signal);
|
|
785
|
+
const message = `House ${housePath(scopeOf(settled))} current where ${short(push.commit)} was refused`;
|
|
786
|
+
await writeCopy(context.directory, (await recorded(settled, files, message, null, signal)).copy, signal);
|
|
787
|
+
return { refused: true, text: refused.text };
|
|
788
|
+
} catch (error) {
|
|
789
|
+
return { refused: true, text: `${refused.text}\n${WORKING_COPY_RECEIVED_REF} is unchanged: ${formatCommandError(error)}` };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function pushed(context, path, at, selector, caller, signal) {
|
|
794
|
+
let copy = await readCopy(path);
|
|
795
|
+
if (copy.push?.state === "submitted") return settle(context, copy, caller, signal);
|
|
796
|
+
const commit = (
|
|
797
|
+
await runGit(at.worktree, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${selector}^{commit}`], {
|
|
798
|
+
capture: true,
|
|
799
|
+
signal,
|
|
800
|
+
failure: "kit_working_copy_commit_unknown",
|
|
801
|
+
})
|
|
802
|
+
)
|
|
803
|
+
.toString("utf8")
|
|
804
|
+
.trim();
|
|
805
|
+
if (copy.push !== undefined) {
|
|
806
|
+
const held = copy.push.acknowledged;
|
|
807
|
+
if (held === undefined || !(await contains(copy.repository, held, commit, signal))) {
|
|
808
|
+
return finish(context, copy, signal);
|
|
809
|
+
}
|
|
810
|
+
copy = without(copy);
|
|
811
|
+
await writeCopy(context.directory, copy, signal);
|
|
812
|
+
}
|
|
813
|
+
if (!copy.active) throw new KitRefusal("kit_working_copy_inactive", housePath(scopeOf(copy)));
|
|
814
|
+
const changes = await derived(copy, await trustedBase(copy, commit, signal), commit, signal);
|
|
815
|
+
if (changes.length === 0) return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
|
|
816
|
+
const branch = await runGit(at.worktree, ["symbolic-ref", "-q", "HEAD"], { capture: true, signal, failure: GIT_FAILED })
|
|
817
|
+
.then((output) => lines(output)[0], () => null);
|
|
818
|
+
const submitted = {
|
|
819
|
+
...copy,
|
|
820
|
+
push: {
|
|
821
|
+
state: "submitted",
|
|
822
|
+
caller: caller.binding,
|
|
823
|
+
operation_id: operationId(),
|
|
824
|
+
changes,
|
|
825
|
+
commit,
|
|
826
|
+
worktree: at.worktree,
|
|
827
|
+
branch,
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
await writeCopy(context.directory, submitted, signal);
|
|
831
|
+
return settle(context, submitted, caller, signal);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export async function pushWorkingCopy(context, request, caller, signal) {
|
|
835
|
+
if (typeof request?.cwd !== "string" || typeof request.commit !== "string") {
|
|
836
|
+
throw new KitRefusal("kit_house_arguments_invalid", "cwd and commit");
|
|
837
|
+
}
|
|
838
|
+
const at = await located(request.cwd, signal);
|
|
839
|
+
const path = copyPath(context.directory, await copyAt(context.directory, at.repository));
|
|
840
|
+
return exclusively(path, () => pushed(context, path, at, request.commit, caller, signal));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function ownerCaller(session) {
|
|
844
|
+
return {
|
|
845
|
+
binding: { owner: true },
|
|
846
|
+
async submit(body, signal) {
|
|
847
|
+
return { refused: null, written: (await door(session, KIT_DOOR_BATCH_PATH, body, signal)).contents };
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function ownerPush(options, request, dependencies = {}) {
|
|
853
|
+
return withSession(options, dependencies, (session, signal) =>
|
|
854
|
+
session.resident === undefined
|
|
855
|
+
? pushWorkingCopy({ directory: options.directory, session }, request, ownerCaller(session), signal)
|
|
856
|
+
: session.push(request, signal),
|
|
857
|
+
);
|
|
858
|
+
}
|