@ahpd/server 0.1.0 → 0.2.0
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 +87 -81
- package/dist/config.d.ts +65 -0
- package/dist/config.js +75 -0
- package/dist/daemon.d.ts +26 -0
- package/dist/daemon.js +160 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +297 -0
- package/dist/pty.d.ts +12 -0
- package/dist/pty.js +56 -0
- package/package.json +15 -25
- package/dist/automations.d.ts +0 -4
- package/dist/automations.d.ts.map +0 -1
- package/dist/automations.js +0 -191
- package/dist/automations.js.map +0 -1
- package/dist/catalog.d.ts +0 -48
- package/dist/catalog.d.ts.map +0 -1
- package/dist/catalog.js +0 -51
- package/dist/catalog.js.map +0 -1
- package/dist/changes.d.ts +0 -4
- package/dist/changes.d.ts.map +0 -1
- package/dist/changes.js +0 -601
- package/dist/changes.js.map +0 -1
- package/dist/cron.d.ts +0 -43
- package/dist/cron.d.ts.map +0 -1
- package/dist/cron.js +0 -211
- package/dist/cron.js.map +0 -1
- package/dist/git.d.ts +0 -16
- package/dist/git.d.ts.map +0 -1
- package/dist/git.js +0 -126
- package/dist/git.js.map +0 -1
- package/dist/host.d.ts +0 -24
- package/dist/host.d.ts.map +0 -1
- package/dist/host.js +0 -5850
- package/dist/host.js.map +0 -1
- package/dist/index.d.ts +0 -36
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -34
- package/dist/index.js.map +0 -1
- package/dist/listen.d.ts +0 -3
- package/dist/listen.d.ts.map +0 -1
- package/dist/listen.js +0 -183
- package/dist/listen.js.map +0 -1
- package/dist/paging.d.ts +0 -38
- package/dist/paging.d.ts.map +0 -1
- package/dist/paging.js +0 -53
- package/dist/paging.js.map +0 -1
- package/dist/paths.d.ts +0 -15
- package/dist/paths.d.ts.map +0 -1
- package/dist/paths.js +0 -19
- package/dist/paths.js.map +0 -1
- package/dist/resources.d.ts +0 -122
- package/dist/resources.d.ts.map +0 -1
- package/dist/resources.js +0 -494
- package/dist/resources.js.map +0 -1
- package/dist/rpc.d.ts +0 -63
- package/dist/rpc.d.ts.map +0 -1
- package/dist/rpc.js +0 -208
- package/dist/rpc.js.map +0 -1
- package/dist/scheduled.d.ts +0 -48
- package/dist/scheduled.d.ts.map +0 -1
- package/dist/scheduled.js +0 -325
- package/dist/scheduled.js.map +0 -1
- package/dist/terminals.d.ts +0 -29
- package/dist/terminals.d.ts.map +0 -1
- package/dist/terminals.js +0 -346
- package/dist/terminals.js.map +0 -1
- package/dist/tools.d.ts +0 -18
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -65
- package/dist/tools.js.map +0 -1
- package/dist/types/agent.d.ts +0 -245
- package/dist/types/agent.d.ts.map +0 -1
- package/dist/types/agent.js +0 -3
- package/dist/types/agent.js.map +0 -1
- package/dist/types/automations.d.ts +0 -164
- package/dist/types/automations.d.ts.map +0 -1
- package/dist/types/automations.js +0 -3
- package/dist/types/automations.js.map +0 -1
- package/dist/types/catalog.d.ts +0 -30
- package/dist/types/catalog.d.ts.map +0 -1
- package/dist/types/catalog.js +0 -3
- package/dist/types/catalog.js.map +0 -1
- package/dist/types/changes.d.ts +0 -272
- package/dist/types/changes.d.ts.map +0 -1
- package/dist/types/changes.js +0 -3
- package/dist/types/changes.js.map +0 -1
- package/dist/types/common.d.ts +0 -9
- package/dist/types/common.d.ts.map +0 -1
- package/dist/types/common.js +0 -3
- package/dist/types/common.js.map +0 -1
- package/dist/types/host.d.ts +0 -372
- package/dist/types/host.d.ts.map +0 -1
- package/dist/types/host.js +0 -3
- package/dist/types/host.js.map +0 -1
- package/dist/types/index.d.ts +0 -20
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -8
- package/dist/types/index.js.map +0 -1
- package/dist/types/listen.d.ts +0 -48
- package/dist/types/listen.d.ts.map +0 -1
- package/dist/types/listen.js +0 -3
- package/dist/types/listen.js.map +0 -1
- package/dist/types/paging.d.ts +0 -10
- package/dist/types/paging.d.ts.map +0 -1
- package/dist/types/paging.js +0 -3
- package/dist/types/paging.js.map +0 -1
- package/dist/types/probe.d.ts +0 -46
- package/dist/types/probe.d.ts.map +0 -1
- package/dist/types/probe.js +0 -3
- package/dist/types/probe.js.map +0 -1
- package/dist/types/resources.d.ts +0 -83
- package/dist/types/resources.d.ts.map +0 -1
- package/dist/types/resources.js +0 -3
- package/dist/types/resources.js.map +0 -1
- package/dist/types/rpc.d.ts +0 -65
- package/dist/types/rpc.d.ts.map +0 -1
- package/dist/types/rpc.js +0 -3
- package/dist/types/rpc.js.map +0 -1
- package/dist/types/session.d.ts +0 -395
- package/dist/types/session.d.ts.map +0 -1
- package/dist/types/session.js +0 -3
- package/dist/types/session.js.map +0 -1
- package/dist/types/terminals.d.ts +0 -131
- package/dist/types/terminals.d.ts.map +0 -1
- package/dist/types/terminals.js +0 -3
- package/dist/types/terminals.js.map +0 -1
- package/dist/types/wire.d.ts +0 -62
- package/dist/types/wire.d.ts.map +0 -1
- package/dist/types/wire.js +0 -13
- package/dist/types/wire.js.map +0 -1
- package/dist/types/worktrees.d.ts +0 -86
- package/dist/types/worktrees.d.ts.map +0 -1
- package/dist/types/worktrees.js +0 -3
- package/dist/types/worktrees.js.map +0 -1
- package/dist/worktrees.d.ts +0 -27
- package/dist/worktrees.d.ts.map +0 -1
- package/dist/worktrees.js +0 -152
- package/dist/worktrees.js.map +0 -1
- package/src/automations.ts +0 -204
- package/src/catalog.ts +0 -53
- package/src/changes.ts +0 -629
- package/src/cron.ts +0 -251
- package/src/git.ts +0 -132
- package/src/host.ts +0 -5947
- package/src/index.ts +0 -40
- package/src/listen.ts +0 -240
- package/src/paging.ts +0 -56
- package/src/paths.ts +0 -19
- package/src/resources.ts +0 -513
- package/src/rpc.ts +0 -222
- package/src/scheduled.ts +0 -369
- package/src/terminals.ts +0 -353
- package/src/tools.ts +0 -64
- package/src/types/agent.ts +0 -255
- package/src/types/automations.ts +0 -164
- package/src/types/catalog.ts +0 -26
- package/src/types/changes.ts +0 -266
- package/src/types/common.ts +0 -9
- package/src/types/host.ts +0 -386
- package/src/types/index.ts +0 -27
- package/src/types/listen.ts +0 -53
- package/src/types/paging.ts +0 -11
- package/src/types/probe.ts +0 -42
- package/src/types/resources.ts +0 -107
- package/src/types/rpc.ts +0 -68
- package/src/types/session.ts +0 -415
- package/src/types/terminals.ts +0 -133
- package/src/types/wire.ts +0 -69
- package/src/types/worktrees.ts +0 -91
- package/src/worktrees.ts +0 -162
package/src/changes.ts
DELETED
|
@@ -1,629 +0,0 @@
|
|
|
1
|
-
/** What git says a directory has changed, as a host's `ChangesetSource`. */
|
|
2
|
-
|
|
3
|
-
import { execFile } from 'node:child_process';
|
|
4
|
-
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
-
import type {
|
|
6
|
-
ChangesSummary, ChangesetFile, ChangesetOperation, ChangesetSource, ChangesetState,
|
|
7
|
-
} from './types/changes.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* How many lines a file has, for one git will not count.
|
|
11
|
-
*
|
|
12
|
-
* Capped, because this is a *summary* and a row saying 40,000 is worth no more
|
|
13
|
-
* than one saying a lot - and reading a hundred-megabyte file to find that out
|
|
14
|
-
* is the expensive way to learn nothing.
|
|
15
|
-
*/
|
|
16
|
-
const lines = async (path: string): Promise<number> => {
|
|
17
|
-
try {
|
|
18
|
-
const text = await readFile(path, 'utf8');
|
|
19
|
-
if (text === '') return 0;
|
|
20
|
-
return text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
21
|
-
}
|
|
22
|
-
catch { return 0; }
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* How many lines differ between two versions, without diffing them.
|
|
27
|
-
*
|
|
28
|
-
* A count, not a diff: the rows carry both sides and a client renders the
|
|
29
|
-
* real thing from those. Computing a proper LCS here to fill in two numbers
|
|
30
|
-
* would be the same work twice, once where nobody can see it.
|
|
31
|
-
*/
|
|
32
|
-
const counted = (before: string, after: string): { added: number; removed: number } => {
|
|
33
|
-
const was = before === '' ? [] : before.split('\n');
|
|
34
|
-
const now = after === '' ? [] : after.split('\n');
|
|
35
|
-
const shared = new Set(was);
|
|
36
|
-
const added = now.filter((row) => !shared.has(row)).length;
|
|
37
|
-
const kept_ = new Set(now);
|
|
38
|
-
const removed = was.filter((row) => !kept_.has(row)).length;
|
|
39
|
-
return { added, removed };
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
/** One `git` run, as text, or nothing when it would not run. */
|
|
43
|
-
const git = (dir: string, args: string[]): Promise<string | undefined> =>
|
|
44
|
-
new Promise((answer) => {
|
|
45
|
-
execFile('git', ['-C', dir, ...args], { timeout: 5000, maxBuffer: 32 * 1024 * 1024 },
|
|
46
|
-
(error, out) => answer(error ? undefined : out.toString()));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* One `git` run, with the failure kept.
|
|
51
|
-
*
|
|
52
|
-
* `git` above answers `undefined` for every kind of not-working, which is the
|
|
53
|
-
* right shape for a question - a directory that is not a repository has no
|
|
54
|
-
* diff, and why is not interesting. An *operation* is the other case: somebody
|
|
55
|
-
* pressed a button, it did not work, and the only useful thing to say is what
|
|
56
|
-
* git said.
|
|
57
|
-
*/
|
|
58
|
-
const run = (dir: string, args: string[]): Promise<{ ok: boolean; out: string; err: string }> =>
|
|
59
|
-
new Promise((answer) => {
|
|
60
|
-
execFile('git', ['-C', dir, ...args], { timeout: 30000, maxBuffer: 32 * 1024 * 1024 },
|
|
61
|
-
(error, out, errOut) => answer({
|
|
62
|
-
ok: !error,
|
|
63
|
-
out: out.toString(),
|
|
64
|
-
err: errOut.toString().trim() || (error ? error.message : ''),
|
|
65
|
-
}));
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
/** The path a `file://` URI names, or nothing for a URI that names none. */
|
|
69
|
-
const pathIn = (dir: string, uri: string): string | undefined => {
|
|
70
|
-
if (!uri.startsWith('file://')) return undefined;
|
|
71
|
-
const path = uri.slice('file://'.length);
|
|
72
|
-
// Inside the directory this changeset is about, and not merely starting with
|
|
73
|
-
// its name: `/src/brb` must not reach `/src/brb_framework`.
|
|
74
|
-
return path === dir || path.startsWith(`${dir}/`) ? path : undefined;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The scheme for the side of an edit that is not on disk.
|
|
79
|
-
*
|
|
80
|
-
* `before` is what a file *used to be*, so no `file://` URI addresses it and
|
|
81
|
-
* the filesystem port cannot serve it. This host mints its own URI and
|
|
82
|
-
* resolves it itself, which is what `ChangesetSource.read` is for.
|
|
83
|
-
*/
|
|
84
|
-
const BEFORE = 'ahp-git:';
|
|
85
|
-
const beforeUri = (dir: string, path: string): string => `${BEFORE}//${dir}/${path}`;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The scheme for a side that was *captured* rather than read.
|
|
89
|
-
*
|
|
90
|
-
* A turn's changeset is what the files looked like on either side of that
|
|
91
|
-
* turn, and neither side is on disk once a later turn has run. Both are held
|
|
92
|
-
* here and served from here.
|
|
93
|
-
*/
|
|
94
|
-
const CAPTURED = 'ahp-edit:';
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Uncommitted changes, from `git status` and `git diff`.
|
|
98
|
-
*
|
|
99
|
-
* ```ts
|
|
100
|
-
* createHost({ path, agents, changes: gitChanges() });
|
|
101
|
-
* ```
|
|
102
|
-
*
|
|
103
|
-
* Only the `uncommitted` scope. `session` and `turn/<id>` are the other two
|
|
104
|
-
* the protocol defines, and neither can be answered from git alone: git knows
|
|
105
|
-
* what a working tree looks like, not which turn made it look that way.
|
|
106
|
-
*/
|
|
107
|
-
/** One file, as a turn found it and as the turn left it. */
|
|
108
|
-
interface Captured {
|
|
109
|
-
before?: string;
|
|
110
|
-
after?: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* The verbs this source offers, declared once.
|
|
115
|
-
*
|
|
116
|
-
* Ids and labels follow the reference host's where it has one - a client that
|
|
117
|
-
* special-cases `commit` should find it spelled the way it expects - and every
|
|
118
|
-
* one of them writes, which is what the host gates on.
|
|
119
|
-
*/
|
|
120
|
-
const COMMIT: ChangesetOperation = {
|
|
121
|
-
id: 'commit',
|
|
122
|
-
label: 'Commit',
|
|
123
|
-
description: 'Commit the working tree, including files git has not been told about',
|
|
124
|
-
scopes: ['changeset'],
|
|
125
|
-
icon: 'git-commit',
|
|
126
|
-
group: 'commit',
|
|
127
|
-
writes: true,
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const DISCARD: ChangesetOperation = {
|
|
131
|
-
id: 'discard',
|
|
132
|
-
label: 'Discard Changes',
|
|
133
|
-
description: 'Put this file back the way HEAD has it',
|
|
134
|
-
scopes: ['resource'],
|
|
135
|
-
confirmation: 'Discard the changes to this file? This cannot be undone.',
|
|
136
|
-
icon: 'discard',
|
|
137
|
-
writes: true,
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Undoing the agent rather than undoing the working tree.
|
|
142
|
-
*
|
|
143
|
-
* Separate from `discard` because the two put a file back to different places:
|
|
144
|
-
* `discard` goes to HEAD, which is where a person's own uncommitted work goes
|
|
145
|
-
* too, and this goes to the state the turn found the file in - which is only
|
|
146
|
-
* knowable because both sides were captured as the tool ran.
|
|
147
|
-
*/
|
|
148
|
-
const REVERT: ChangesetOperation = {
|
|
149
|
-
id: 'revert',
|
|
150
|
-
label: 'Revert This File',
|
|
151
|
-
description: 'Put this file back the way the agent found it',
|
|
152
|
-
scopes: ['resource'],
|
|
153
|
-
confirmation: 'Put this file back the way the agent found it? Anything written since is lost.',
|
|
154
|
-
icon: 'discard',
|
|
155
|
-
writes: true,
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
export function gitChanges(): ChangesetSource {
|
|
159
|
-
/**
|
|
160
|
-
* What each turn changed, per session.
|
|
161
|
-
*
|
|
162
|
-
* `session -> turn -> path -> both sides`. Held rather than derived because
|
|
163
|
-
* git cannot answer it: a working tree says what it looks like now, so a
|
|
164
|
-
* turn asked about after two more have run would be handed their work too.
|
|
165
|
-
*/
|
|
166
|
-
const seen = new Map<string, Map<string, Map<string, Captured>>>();
|
|
167
|
-
|
|
168
|
-
/** The text held for a captured side, by the URI minted for it. */
|
|
169
|
-
const kept = new Map<string, string>();
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Which files somebody has ticked off, per changeset.
|
|
173
|
-
*
|
|
174
|
-
* A reader's bookkeeping rather than anything about the files: keyed by the
|
|
175
|
-
* scope being read, because reviewing a turn is not reviewing the session
|
|
176
|
-
* that contains it.
|
|
177
|
-
*/
|
|
178
|
-
const reviewed = new Map<string, Set<string>>();
|
|
179
|
-
const reviewKey = (session: string, scope: string): string => `${session}\u0000${scope}`;
|
|
180
|
-
|
|
181
|
-
const capturedUri = (session: string, turn: string, path: string, phase: string): string =>
|
|
182
|
-
`${CAPTURED}//${encodeURIComponent(session)}/${encodeURIComponent(turn)}/${phase}${path}`;
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Fold a run of turns into one edit per file.
|
|
186
|
-
*
|
|
187
|
-
* The first `before` and the last `after`, which is what a range of turns
|
|
188
|
-
* changed taken together: a file edited three times was found in one state
|
|
189
|
-
* and left in another, and the two states in between are the middle of a
|
|
190
|
-
* diff nobody asked for.
|
|
191
|
-
*/
|
|
192
|
-
const fold = (turns: Iterable<Map<string, Captured>>): Map<string, Captured> => {
|
|
193
|
-
const flat = new Map<string, Captured>();
|
|
194
|
-
for (const files of turns) {
|
|
195
|
-
for (const [path, sides] of files) {
|
|
196
|
-
const already = flat.get(path);
|
|
197
|
-
// The first `before` and the last `after`: a session's changeset is
|
|
198
|
-
// the whole conversation as one edit, not the last turn of it.
|
|
199
|
-
flat.set(path, {
|
|
200
|
-
...(already?.before !== undefined ? { before: already.before } : sides.before !== undefined ? { before: sides.before } : {}),
|
|
201
|
-
...(sides.after !== undefined ? { after: sides.after } : already?.after !== undefined ? { after: already.after } : {}),
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return flat;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
/** Every file a session has touched, in the order it touched them. */
|
|
209
|
-
const across = (session: string): Map<string, Captured> =>
|
|
210
|
-
fold([...(seen.get(session) ?? new Map()).values()]);
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* The turns from one to another, inclusive.
|
|
214
|
-
*
|
|
215
|
-
* Insertion order is turn order - a turn is first seen when its first tool
|
|
216
|
-
* runs - so a range is a slice. Either end being unknown is a question about
|
|
217
|
-
* something that did not happen, and answers nothing rather than everything.
|
|
218
|
-
*/
|
|
219
|
-
const between = (session: string, from: string, to: string): Map<string, Captured> | undefined => {
|
|
220
|
-
const turns = seen.get(session);
|
|
221
|
-
if (!turns) return undefined;
|
|
222
|
-
const order = [...turns.keys()];
|
|
223
|
-
const start = order.indexOf(from);
|
|
224
|
-
const end = order.indexOf(to);
|
|
225
|
-
if (start < 0 || end < 0) return undefined;
|
|
226
|
-
const [lo, hi] = start <= end ? [start, end] : [end, start];
|
|
227
|
-
return fold(order.slice(lo, hi + 1).map((id) => turns.get(id) as Map<string, Captured>));
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Captured sides as the protocol's rows, with both sides fetchable.
|
|
232
|
-
*
|
|
233
|
-
* Minting a URI and remembering what is behind it are the same act, so they
|
|
234
|
-
* are done in the same place: a row that names content nothing can resolve
|
|
235
|
-
* is a row that opens onto an error.
|
|
236
|
-
*/
|
|
237
|
-
const rowsOf = (session: string, turn: string, files: Map<string, Captured>): ChangesetFile[] => {
|
|
238
|
-
const ticked = reviewed.get(reviewKey(session, turn));
|
|
239
|
-
return [...files].map(([path, sides]) => {
|
|
240
|
-
const uri = `file://${path}`;
|
|
241
|
-
const before = capturedUri(session, turn, path, 'before');
|
|
242
|
-
const after = capturedUri(session, turn, path, 'after');
|
|
243
|
-
if (sides.before !== undefined) kept.set(before, sides.before);
|
|
244
|
-
if (sides.after !== undefined) kept.set(after, sides.after);
|
|
245
|
-
return {
|
|
246
|
-
id: uri,
|
|
247
|
-
edit: {
|
|
248
|
-
// An empty `before` is a file the turn created, and the protocol
|
|
249
|
-
// says a creation by leaving the side out rather than by a word.
|
|
250
|
-
...(sides.before ? { before: { uri, content: { uri: before } } } : {}),
|
|
251
|
-
...(sides.after !== undefined ? { after: { uri, content: { uri: after } } } : {}),
|
|
252
|
-
diff: counted(sides.before ?? '', sides.after ?? ''),
|
|
253
|
-
},
|
|
254
|
-
// Absent is not-yet-reviewed, which is what the protocol says a
|
|
255
|
-
// missing value means, so only a tick is worth sending.
|
|
256
|
-
...(ticked?.has(uri) ? { reviewed: true } : {}),
|
|
257
|
-
};
|
|
258
|
-
});
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* The files one captured scope holds, by absolute path.
|
|
263
|
-
*
|
|
264
|
-
* The same branch `state` takes, wanted twice: an operation on a captured
|
|
265
|
-
* scope needs the side the turn *found* the file in, and there is nowhere
|
|
266
|
-
* else that survives - git only ever knows what the tree looks like now.
|
|
267
|
-
*/
|
|
268
|
-
const capturedFor = (session: string, scope: string): Map<string, Captured> | undefined => {
|
|
269
|
-
if (scope === 'session') return across(session);
|
|
270
|
-
if (scope.startsWith('compare/')) {
|
|
271
|
-
const [from, to] = scope.slice('compare/'.length).split('/');
|
|
272
|
-
if (!from || !to) return undefined;
|
|
273
|
-
return between(session, from, to);
|
|
274
|
-
}
|
|
275
|
-
if (scope.startsWith('turn/')) return seen.get(session)?.get(scope.slice('turn/'.length));
|
|
276
|
-
return undefined;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
/** The last answer per directory, so a catalogue of rows is not a hundred `git` runs. */
|
|
280
|
-
const held = new Map<string, { files: ChangesetFile[]; summary: ChangesSummary }>();
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* What changed, as one pass over `git status` and one over `git diff`.
|
|
284
|
-
*
|
|
285
|
-
* `--porcelain=v1 -z` because a filename may contain anything a shell would
|
|
286
|
-
* otherwise eat, newlines included; the NUL form is the only one that
|
|
287
|
-
* survives a path somebody made on purpose.
|
|
288
|
-
*/
|
|
289
|
-
const look = async (dir: string): Promise<{ files: ChangesetFile[]; summary: ChangesSummary } | undefined> => {
|
|
290
|
-
const status = await git(dir, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
291
|
-
if (status === undefined) return undefined;
|
|
292
|
-
|
|
293
|
-
/** Line counts per path, for the files git can already diff. */
|
|
294
|
-
const counts = new Map<string, { added: number; removed: number }>();
|
|
295
|
-
const numstat = await git(dir, ['diff', '--numstat', '-z', 'HEAD']) ?? '';
|
|
296
|
-
/*
|
|
297
|
-
* `added<TAB>removed<TAB>path` per record, NUL between records.
|
|
298
|
-
*
|
|
299
|
-
* The tabs are *inside* a record and only the separator is NUL, which is
|
|
300
|
-
* the whole point of `-z`: a path may contain a tab, and it may contain a
|
|
301
|
-
* newline, and neither ends the record. A rename writes an empty path and
|
|
302
|
-
* then two records of its own, old name and new.
|
|
303
|
-
*/
|
|
304
|
-
const counted = numstat.split('\0');
|
|
305
|
-
for (let i = 0; i < counted.length; i++) {
|
|
306
|
-
const record = counted[i] as string;
|
|
307
|
-
if (record === '') continue;
|
|
308
|
-
const [rawAdded, rawRemoved, path] = record.split('\t');
|
|
309
|
-
// `-` on both sides is a binary file, which has no lines to count.
|
|
310
|
-
const added = Number(rawAdded);
|
|
311
|
-
const removed = Number(rawRemoved);
|
|
312
|
-
const named = path === '' || path === undefined
|
|
313
|
-
// A rename: the two records after this one are the old and new names,
|
|
314
|
-
// and it is the new one the working tree has.
|
|
315
|
-
? (i += 2, counted[i] as string | undefined)
|
|
316
|
-
: path;
|
|
317
|
-
if (!named) continue;
|
|
318
|
-
counts.set(named, {
|
|
319
|
-
added: Number.isFinite(added) ? added : 0,
|
|
320
|
-
removed: Number.isFinite(removed) ? removed : 0,
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const files: ChangesetFile[] = [];
|
|
325
|
-
const summary: ChangesSummary = { files: 0, additions: 0, deletions: 0 };
|
|
326
|
-
const records = status.split('\0').filter((record) => record !== '');
|
|
327
|
-
for (const record of records) {
|
|
328
|
-
// `XY <path>`: two status letters, a space, then the path.
|
|
329
|
-
const code = record.slice(0, 2);
|
|
330
|
-
const path = record.slice(3);
|
|
331
|
-
if (path === '') continue;
|
|
332
|
-
const gone = code.includes('D');
|
|
333
|
-
const fresh = code.includes('A') || code.includes('?');
|
|
334
|
-
const uri = `file://${dir}/${path}`;
|
|
335
|
-
// An untracked file is in no diff against HEAD, so git reports nothing
|
|
336
|
-
// for it. Every line of it is an addition, which is what it is.
|
|
337
|
-
const count = counts.get(path) ?? (fresh
|
|
338
|
-
? { added: await lines(`${dir}/${path}`), removed: 0 }
|
|
339
|
-
: { added: 0, removed: 0 });
|
|
340
|
-
|
|
341
|
-
files.push({
|
|
342
|
-
id: uri,
|
|
343
|
-
edit: {
|
|
344
|
-
// Absent `before` is a creation and absent `after` a deletion. The
|
|
345
|
-
// protocol says both by leaving a side out rather than by a word.
|
|
346
|
-
...(fresh ? {} : {
|
|
347
|
-
before: { uri, content: { uri: beforeUri(dir, path) } },
|
|
348
|
-
}),
|
|
349
|
-
...(gone ? {} : {
|
|
350
|
-
after: { uri, content: { uri } },
|
|
351
|
-
}),
|
|
352
|
-
diff: { added: count.added, removed: count.removed },
|
|
353
|
-
},
|
|
354
|
-
});
|
|
355
|
-
summary.files = (summary.files ?? 0) + 1;
|
|
356
|
-
summary.additions = (summary.additions ?? 0) + count.added;
|
|
357
|
-
summary.deletions = (summary.deletions ?? 0) + count.removed;
|
|
358
|
-
}
|
|
359
|
-
return { files, summary };
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
scopes: (dir, session) => {
|
|
364
|
-
const scopes = held.has(dir)
|
|
365
|
-
? [{
|
|
366
|
-
id: 'uncommitted',
|
|
367
|
-
label: 'Uncommitted Changes',
|
|
368
|
-
description: 'The working tree, against HEAD',
|
|
369
|
-
changeKind: 'uncommitted',
|
|
370
|
-
// Not reviewable: the working tree is whatever it is now, and a
|
|
371
|
-
// tick against a file that something else may rewrite underneath it
|
|
372
|
-
// is bookkeeping about a thing that has moved.
|
|
373
|
-
}]
|
|
374
|
-
: [];
|
|
375
|
-
const turns = seen.get(session);
|
|
376
|
-
if (!turns || turns.size === 0) return scopes;
|
|
377
|
-
// The session's own first. A client showing one changeset shows the
|
|
378
|
-
// first that needs no variable filling in, and what a *conversation*
|
|
379
|
-
// changed is the one that belongs beside a conversation - the working
|
|
380
|
-
// tree includes whatever else happened to the directory meanwhile.
|
|
381
|
-
return [
|
|
382
|
-
{
|
|
383
|
-
id: 'session',
|
|
384
|
-
label: 'This Session',
|
|
385
|
-
description: 'Everything this conversation changed',
|
|
386
|
-
changeKind: 'session',
|
|
387
|
-
reviewable: true,
|
|
388
|
-
},
|
|
389
|
-
...scopes,
|
|
390
|
-
// Templates, which is how the protocol offers a scope that has to be
|
|
391
|
-
// filled in: a client expands them from turns it can already see.
|
|
392
|
-
{
|
|
393
|
-
id: 'turn/{turnId}',
|
|
394
|
-
label: 'This Turn',
|
|
395
|
-
description: 'What one turn changed',
|
|
396
|
-
changeKind: 'turn',
|
|
397
|
-
reviewable: true,
|
|
398
|
-
},
|
|
399
|
-
{
|
|
400
|
-
id: 'compare/{originalTurnId}/{modifiedTurnId}',
|
|
401
|
-
label: 'Between Two Turns',
|
|
402
|
-
description: 'What changed from one turn to another',
|
|
403
|
-
changeKind: 'compare-turns',
|
|
404
|
-
reviewable: true,
|
|
405
|
-
},
|
|
406
|
-
];
|
|
407
|
-
},
|
|
408
|
-
|
|
409
|
-
state: async (dir, session, scope) => {
|
|
410
|
-
if (scope === 'uncommitted') {
|
|
411
|
-
const found = held.get(dir) ?? await look(dir);
|
|
412
|
-
if (!found) return undefined;
|
|
413
|
-
return { status: 'ready', files: found.files };
|
|
414
|
-
}
|
|
415
|
-
if (scope === 'session') {
|
|
416
|
-
const files = across(session);
|
|
417
|
-
if (files.size === 0) return { status: 'ready', files: [] };
|
|
418
|
-
return { status: 'ready', files: rowsOf(session, 'session', files) };
|
|
419
|
-
}
|
|
420
|
-
if (scope.startsWith('compare/')) {
|
|
421
|
-
const [from, to] = scope.slice('compare/'.length).split('/');
|
|
422
|
-
if (!from || !to) return undefined;
|
|
423
|
-
const files = between(session, from, to);
|
|
424
|
-
if (!files) return undefined;
|
|
425
|
-
return { status: 'ready', files: rowsOf(session, `compare/${from}/${to}`, files) };
|
|
426
|
-
}
|
|
427
|
-
if (scope.startsWith('turn/')) {
|
|
428
|
-
const turn = scope.slice('turn/'.length);
|
|
429
|
-
const files = seen.get(session)?.get(turn);
|
|
430
|
-
// A turn nobody has heard of is not an empty changeset - it is a
|
|
431
|
-
// question about something that did not happen.
|
|
432
|
-
if (!files) return undefined;
|
|
433
|
-
return { status: 'ready', files: rowsOf(session, turn, files) };
|
|
434
|
-
}
|
|
435
|
-
return undefined;
|
|
436
|
-
},
|
|
437
|
-
|
|
438
|
-
/*
|
|
439
|
-
* Both sides of a file, captured as the tool runs.
|
|
440
|
-
*
|
|
441
|
-
* `before` is read as the tool is announced and `after` when its result
|
|
442
|
-
* arrives. A file that did not exist reads as empty, which is what a
|
|
443
|
-
* creation is.
|
|
444
|
-
*/
|
|
445
|
-
observe: (dir, session, turnId, path, phase) => {
|
|
446
|
-
void (async () => {
|
|
447
|
-
const text = await readFile(path, 'utf8').catch(() => undefined);
|
|
448
|
-
const turns = seen.get(session) ?? new Map<string, Map<string, Captured>>();
|
|
449
|
-
seen.set(session, turns);
|
|
450
|
-
const files = turns.get(turnId) ?? new Map<string, Captured>();
|
|
451
|
-
turns.set(turnId, files);
|
|
452
|
-
const sides = files.get(path) ?? {};
|
|
453
|
-
// The first `before` wins: a turn that edits one file twice found it
|
|
454
|
-
// in one state, and the second read is already its own work.
|
|
455
|
-
if (phase === 'before' && sides.before === undefined) sides.before = text ?? '';
|
|
456
|
-
if (phase === 'after') sides.after = text ?? '';
|
|
457
|
-
files.set(path, sides);
|
|
458
|
-
|
|
459
|
-
kept.set(capturedUri(session, turnId, path, phase), text ?? '');
|
|
460
|
-
|
|
461
|
-
/*
|
|
462
|
-
* A file that has changed again is not the file that was reviewed.
|
|
463
|
-
*
|
|
464
|
-
* The protocol makes this the server's job - it is the authority on
|
|
465
|
-
* what changed - and says to reset explicitly rather than leave a tick
|
|
466
|
-
* standing against content nobody has read. Only on `after`, because
|
|
467
|
-
* `before` is the state a tick was about.
|
|
468
|
-
*/
|
|
469
|
-
if (phase === 'after') {
|
|
470
|
-
for (const scope of [turnId, 'session']) {
|
|
471
|
-
reviewed.get(reviewKey(session, scope))?.delete(`file://${path}`);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
})().catch(() => {});
|
|
475
|
-
},
|
|
476
|
-
|
|
477
|
-
summary: (dir) => held.get(dir)?.summary,
|
|
478
|
-
|
|
479
|
-
/*
|
|
480
|
-
* The `before` side, out of git rather than off the disk.
|
|
481
|
-
*
|
|
482
|
-
* `git show HEAD:<path>` is what the file was at the last commit, which is
|
|
483
|
-
* the only place that version still exists.
|
|
484
|
-
*/
|
|
485
|
-
read: async (uri) => {
|
|
486
|
-
// Captured sides are held, not fetched: neither is on disk any more.
|
|
487
|
-
if (uri.startsWith(CAPTURED)) {
|
|
488
|
-
const text = kept.get(uri);
|
|
489
|
-
return text === undefined ? undefined : { data: text, encoding: 'utf-8' };
|
|
490
|
-
}
|
|
491
|
-
if (!uri.startsWith(BEFORE)) return undefined;
|
|
492
|
-
const rest = uri.slice(`${BEFORE}//`.length);
|
|
493
|
-
// The directory is the longest known one this URI starts with: a path
|
|
494
|
-
// has slashes and so does a directory, and splitting on the first one
|
|
495
|
-
// would name neither.
|
|
496
|
-
const dir = [...held.keys()]
|
|
497
|
-
.filter((known) => rest.startsWith(`${known}/`))
|
|
498
|
-
.sort((a, b) => b.length - a.length)[0];
|
|
499
|
-
if (dir === undefined) return undefined;
|
|
500
|
-
const path = rest.slice(dir.length + 1);
|
|
501
|
-
const data = await git(dir, ['show', `HEAD:${path}`]);
|
|
502
|
-
// A file that is not in HEAD has no before, and empty is the truthful
|
|
503
|
-
// answer for one: it did not exist.
|
|
504
|
-
return { data: data ?? '', encoding: 'utf-8' };
|
|
505
|
-
},
|
|
506
|
-
|
|
507
|
-
/*
|
|
508
|
-
* Ticked off, or cleared.
|
|
509
|
-
*
|
|
510
|
-
* The one thing a client may write here, and it writes nothing to disk:
|
|
511
|
-
* it is a reader's note about a diff they are working through. Answers
|
|
512
|
-
* whether it moved, so a client that ticks a file already ticked does not
|
|
513
|
-
* make every other client redraw.
|
|
514
|
-
*/
|
|
515
|
-
review: (_dir, session, scope, files, isReviewed) => {
|
|
516
|
-
const key = reviewKey(session, scope);
|
|
517
|
-
const ticked = reviewed.get(key) ?? new Set<string>();
|
|
518
|
-
reviewed.set(key, ticked);
|
|
519
|
-
let moved = false;
|
|
520
|
-
for (const file of files) {
|
|
521
|
-
if (isReviewed && !ticked.has(file)) { ticked.add(file); moved = true; }
|
|
522
|
-
if (!isReviewed && ticked.delete(file)) moved = true;
|
|
523
|
-
}
|
|
524
|
-
return moved;
|
|
525
|
-
},
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* What can be done to one scope, and only what can be done *now*.
|
|
529
|
-
*
|
|
530
|
-
* A working tree with nothing in it offers no commit, and a scope holding
|
|
531
|
-
* no captured files offers no revert - an operation advertised against
|
|
532
|
-
* nothing is a button that fails when pressed, and the protocol's whole
|
|
533
|
-
* access model is that a client may only invoke what it was offered.
|
|
534
|
-
*/
|
|
535
|
-
operations: (dir, session, scope) => {
|
|
536
|
-
// Not a repository. `held` is only ever set for a directory `git status`
|
|
537
|
-
// answered for, which is the same question as "is there git here".
|
|
538
|
-
if (!held.has(dir)) return [];
|
|
539
|
-
if (scope === 'uncommitted') return held.get(dir)?.summary?.files ? [COMMIT, DISCARD] : [];
|
|
540
|
-
const files = capturedFor(session, scope);
|
|
541
|
-
return files && files.size > 0 ? [REVERT] : [];
|
|
542
|
-
},
|
|
543
|
-
|
|
544
|
-
/*
|
|
545
|
-
* Run one.
|
|
546
|
-
*
|
|
547
|
-
* Everything about *whether* this is allowed happened before the call: the
|
|
548
|
-
* host checked the id against what `operations` offered for this scope,
|
|
549
|
-
* checked the target against the operation's scopes, and checked that a
|
|
550
|
-
* write grant is held. What is left is the doing, and saying what git said
|
|
551
|
-
* when it did not work.
|
|
552
|
-
*/
|
|
553
|
-
invoke: async ({ dir, session, scope, operationId, target, subject }) => {
|
|
554
|
-
if (operationId === 'commit') {
|
|
555
|
-
// `-A`, including files git has not been told about: the changeset this
|
|
556
|
-
// was invoked on counted untracked files as changes, and committing
|
|
557
|
-
// less than was listed would commit something other than what was
|
|
558
|
-
// shown.
|
|
559
|
-
const staged = await run(dir, ['add', '-A']);
|
|
560
|
-
if (!staged.ok) throw new Error(`Could not stage: ${staged.err}`);
|
|
561
|
-
// The session's own title, which is the sentence somebody already wrote
|
|
562
|
-
// about this work. A generated one would need the agent, and running a
|
|
563
|
-
// turn to commit a turn is a lot of machinery for a subject line.
|
|
564
|
-
const line = (subject ?? '').split('\n')[0]?.trim();
|
|
565
|
-
const message = line !== undefined && line !== '' ? line : 'Changes from an agent session';
|
|
566
|
-
const done = await run(dir, ['commit', '-m', message]);
|
|
567
|
-
if (!done.ok) throw new Error(`Could not commit: ${done.err || done.out.trim()}`);
|
|
568
|
-
const at = (await git(dir, ['rev-parse', '--short', 'HEAD']))?.trim();
|
|
569
|
-
return { message: at ? `Committed ${at}: ${message}` : `Committed: ${message}` };
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const path = target?.resource === undefined ? undefined : pathIn(dir, target.resource);
|
|
573
|
-
// Refused rather than clamped: a target outside this directory is a
|
|
574
|
-
// client asking to write somewhere this changeset is not about.
|
|
575
|
-
if (path === undefined) throw new Error('That file is not in this directory.');
|
|
576
|
-
const named = path.slice(dir.length + 1);
|
|
577
|
-
|
|
578
|
-
if (operationId === 'discard') {
|
|
579
|
-
/*
|
|
580
|
-
* Tracked and untracked are different undos.
|
|
581
|
-
*
|
|
582
|
-
* A file git knows goes back to HEAD; a file it does not was never
|
|
583
|
-
* anywhere else, so putting it back means removing it. `restore` will
|
|
584
|
-
* not do the second - it fails on a pathspec it has no record of - so
|
|
585
|
-
* the failure is the signal to try the other one.
|
|
586
|
-
*/
|
|
587
|
-
const back = await run(dir, ['restore', '--staged', '--worktree', '--source=HEAD', '--', path]);
|
|
588
|
-
if (!back.ok) {
|
|
589
|
-
const cleaned = await run(dir, ['clean', '-f', '--', path]);
|
|
590
|
-
if (!cleaned.ok) throw new Error(`Could not discard: ${back.err || cleaned.err}`);
|
|
591
|
-
}
|
|
592
|
-
return { message: `Discarded ${named}` };
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
if (operationId === 'revert') {
|
|
596
|
-
const sides = capturedFor(session, scope)?.get(path);
|
|
597
|
-
if (!sides) throw new Error('This changeset does not hold that file.');
|
|
598
|
-
/*
|
|
599
|
-
* No `before` is a file the turn created, and putting a creation back
|
|
600
|
-
* means it should not be there.
|
|
601
|
-
*
|
|
602
|
-
* Empty counts as none, which is the same reading `rowsOf` gives when
|
|
603
|
-
* it decides whether to draw a `before` side at all - one rule, so a
|
|
604
|
-
* row that shows as a creation reverts as one.
|
|
605
|
-
*/
|
|
606
|
-
if (sides.before === undefined || sides.before === '') {
|
|
607
|
-
await rm(path, { force: true });
|
|
608
|
-
return { message: `Removed ${named}, which this changeset created` };
|
|
609
|
-
}
|
|
610
|
-
await writeFile(path, sides.before, 'utf8');
|
|
611
|
-
return { message: `Reverted ${named}` };
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
throw new Error(`No operation called ${operationId}`);
|
|
615
|
-
},
|
|
616
|
-
|
|
617
|
-
refresh: async (dir) => {
|
|
618
|
-
const found = await look(dir);
|
|
619
|
-
const before = JSON.stringify(held.get(dir)?.summary ?? null);
|
|
620
|
-
if (!found) {
|
|
621
|
-
if (!held.has(dir)) return false;
|
|
622
|
-
held.delete(dir);
|
|
623
|
-
return true;
|
|
624
|
-
}
|
|
625
|
-
held.set(dir, found);
|
|
626
|
-
return JSON.stringify(found.summary) !== before;
|
|
627
|
-
},
|
|
628
|
-
};
|
|
629
|
-
}
|