aegis-desktop 0.5.4 → 0.6.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/lib/local/autonomous.js +600 -0
- package/lib/local/engine.js +26 -1
- package/lib/local/git-scope.js +163 -9
- package/lib/local/queue.js +534 -0
- package/main.js +834 -29
- package/package.json +2 -2
- package/preload.js +93 -0
- package/renderer/app.js +384 -3
- package/renderer/index.html +45 -0
- package/vendor/update.js +76 -11
package/lib/local/git-scope.js
CHANGED
|
@@ -4,15 +4,21 @@
|
|
|
4
4
|
* Minimal git worktree introspection: where the repo root is, and what is
|
|
5
5
|
* dirty right now with a content hash per path.
|
|
6
6
|
*
|
|
7
|
-
* Ported from aegiscodex-dev/src/git-scope.js (ESM → CommonJS).
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* vendored: a copy of code nothing calls is a copy that drifts unnoticed.
|
|
7
|
+
* Ported from aegiscodex-dev/src/git-scope.js (ESM → CommonJS). `turn-guard.js`
|
|
8
|
+
* uses `gitRoot` and `gitStatusSnapshot`; the queue now hosts the commit path
|
|
9
|
+
* too, so `foreignChanges` and `scopedCommit` are carried over as well — they
|
|
10
|
+
* were deliberately absent while nothing here queued work, because a copy of
|
|
11
|
+
* code nothing calls is a copy that drifts unnoticed.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* ATTRIBUTION IS TWO CONDITIONS, NOT ONE. "Dirty before AND byte-identical
|
|
14
|
+
* now" only excludes work that nobody touched — it cannot see a concurrent
|
|
15
|
+
* writer who edits a file *during* the task window, because that file is no
|
|
16
|
+
* longer byte-identical and so looks like ours. Commit 96fb64f swept exactly
|
|
17
|
+
* such a file (desktop/electron-builder.yml, edited by another live session)
|
|
18
|
+
* into an unrelated commit that way. So `scopedCommit` also requires positive
|
|
19
|
+
* attribution: a path is staged only if the caller reports that THIS task's own
|
|
20
|
+
* tool layer wrote it (`written`). Everything else dirty — foreign, or merely
|
|
21
|
+
* unattributed — is left alone and reported.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
24
|
const fs = require('node:fs');
|
|
@@ -68,4 +74,152 @@ function gitStatusSnapshot(cwd) {
|
|
|
68
74
|
return snap;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Paths that were dirty *before* and are byte-identical now: another agent's
|
|
79
|
+
* in-flight work, not ours. These must never be committed.
|
|
80
|
+
*
|
|
81
|
+
* Byte-identical is the test, not "was dirty": a path that was dirty before and
|
|
82
|
+
* that this task then EDITED has a different hash, so it is ours to commit. Only
|
|
83
|
+
* untouched foreign work is excluded.
|
|
84
|
+
*/
|
|
85
|
+
function foreignChanges(cwd, before) {
|
|
86
|
+
if (!before) return [];
|
|
87
|
+
const now = gitStatusSnapshot(cwd);
|
|
88
|
+
if (!now) return [];
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const [p, hash] of now) {
|
|
91
|
+
if (before.has(p) && before.get(p) === hash) out.push(p);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A path the caller reported writing, as repo-root-relative POSIX text.
|
|
98
|
+
*
|
|
99
|
+
* The tool layer reports what the MODEL asked for (`file_path` as typed), which
|
|
100
|
+
* may be absolute or relative to the task's own working directory — not
|
|
101
|
+
* necessarily this process's `cwd`. Returns '' for anything outside the repo:
|
|
102
|
+
* a write that cannot be attributed to a path git reports is not something this
|
|
103
|
+
* function may guess about.
|
|
104
|
+
*/
|
|
105
|
+
function toRepoRelative(root, base, p) {
|
|
106
|
+
const raw = String(p == null ? '' : p).trim();
|
|
107
|
+
if (!raw) return '';
|
|
108
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(base || root, raw);
|
|
109
|
+
const rel = path.relative(root, abs);
|
|
110
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
|
|
111
|
+
return rel.split(path.sep).join('/');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The repo-relative set of paths the task's own tool layer reported writing. */
|
|
115
|
+
function writtenSet(root, base, written) {
|
|
116
|
+
const out = new Set();
|
|
117
|
+
for (const p of written || []) {
|
|
118
|
+
const rel = toRepoRelative(root, base, p);
|
|
119
|
+
if (rel) out.add(rel);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Split the current dirty set by attribution. `ours` is what this task wrote
|
|
126
|
+
* and changed; `left` is everything else git reports as dirty — a peer's
|
|
127
|
+
* in-flight work, plus our own changes we cannot positively attribute (a file a
|
|
128
|
+
* shell command created names no path in the tool call, so it is reported, not
|
|
129
|
+
* assumed).
|
|
130
|
+
*
|
|
131
|
+
* @returns {null|{root:string, ours:string[], left:string[], written:Set<string>}}
|
|
132
|
+
*/
|
|
133
|
+
function attributedChanges(cwd, { before, written } = {}) {
|
|
134
|
+
const root = gitRoot(cwd);
|
|
135
|
+
if (!root) return null;
|
|
136
|
+
const now = gitStatusSnapshot(cwd);
|
|
137
|
+
if (!now) return null;
|
|
138
|
+
const mine = writtenSet(root, cwd, written);
|
|
139
|
+
const ours = [];
|
|
140
|
+
const left = [];
|
|
141
|
+
for (const [p, hash] of now) {
|
|
142
|
+
// "Differs from the pre-task snapshot": new since then, or the bytes moved.
|
|
143
|
+
const changed = !(before && before.has(p) && before.get(p) === hash);
|
|
144
|
+
if (changed && mine.has(p)) ours.push(p);
|
|
145
|
+
else left.push(p);
|
|
146
|
+
}
|
|
147
|
+
return { root, ours, left, written: mine };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Commit only the changes this task is known to have made.
|
|
152
|
+
*
|
|
153
|
+
* The rule, in one line: stage a dirty path only if it DIFFERS from the
|
|
154
|
+
* pre-task snapshot AND this task's tool layer reports writing it. Two
|
|
155
|
+
* conditions, because either alone is unsafe: "changed since the snapshot"
|
|
156
|
+
* sweeps a concurrent writer who edits mid-run, and "was dirty before and is
|
|
157
|
+
* byte-identical now" cannot be the only exclusion because it sees only the
|
|
158
|
+
* writer who stopped.
|
|
159
|
+
*
|
|
160
|
+
* `before` is the `gitStatusSnapshot(cwd)` taken immediately before the task
|
|
161
|
+
* ran and `written` the paths the caller's tool layer wrote during it; without
|
|
162
|
+
* either there is no basis for attribution, and the only safe move is to refuse
|
|
163
|
+
* (or stage nothing) — `git add -A` here would commit a concurrent agent's
|
|
164
|
+
* half-finished edit under this task's message.
|
|
165
|
+
*
|
|
166
|
+
* @returns {{ok?:boolean,skipped?:boolean,reason?:string,error?:string,
|
|
167
|
+
* message?:string,staged?:number,paths?:string[],foreign?:string[],
|
|
168
|
+
* identicalForeign?:string[]}}
|
|
169
|
+
*/
|
|
170
|
+
function scopedCommit(cwd, { message, before, written } = {}) {
|
|
171
|
+
if (!gitRoot(cwd)) return { skipped: true, reason: 'not a git repo' };
|
|
172
|
+
|
|
173
|
+
// No basis for attribution — refuse rather than sweep.
|
|
174
|
+
if (!before) {
|
|
175
|
+
return { ok: false, error: 'no pre-task snapshot; refusing to sweep the working tree' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const attributed = attributedChanges(cwd, { before, written });
|
|
179
|
+
if (!attributed) return { skipped: true, reason: 'not a git repo' };
|
|
180
|
+
const { root, ours, left } = attributed;
|
|
181
|
+
|
|
182
|
+
if (!ours.length) {
|
|
183
|
+
const untouched = foreignChanges(cwd, before);
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
skipped: true,
|
|
187
|
+
foreign: left,
|
|
188
|
+
identicalForeign: untouched,
|
|
189
|
+
reason: left.length
|
|
190
|
+
? `no changes of our own (left ${left.length} change(s) this task did not write)`
|
|
191
|
+
: 'no changes',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// pathspec-from-file keeps an arbitrarily long (or space/NUL-bearing) list
|
|
196
|
+
// safe — a 400-file commit is fine, a path with a newline in it is not.
|
|
197
|
+
const add = spawnSync('git', ['add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], {
|
|
198
|
+
cwd: root,
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
input: ours.join('\0'),
|
|
201
|
+
});
|
|
202
|
+
if (add.status !== 0) return { ok: false, error: add.stderr || 'git add failed' };
|
|
203
|
+
|
|
204
|
+
const commit = spawnSync('git', ['commit', '-m', message], { cwd: root, encoding: 'utf8' });
|
|
205
|
+
if (commit.status !== 0) {
|
|
206
|
+
// "nothing to commit" is a success for our purposes: the changes we staged
|
|
207
|
+
// were already committed by somebody else, and the task's work is done.
|
|
208
|
+
if (/nothing to commit|no changes added/i.test(commit.stdout + commit.stderr)) {
|
|
209
|
+
return { ok: true, skipped: true, foreign: left, reason: 'no changes' };
|
|
210
|
+
}
|
|
211
|
+
return { ok: false, error: commit.stderr || commit.stdout };
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, message, foreign: left, paths: ours, staged: ours.length };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
gitRoot,
|
|
218
|
+
contentHash,
|
|
219
|
+
gitStatusSnapshot,
|
|
220
|
+
foreignChanges,
|
|
221
|
+
toRepoRelative,
|
|
222
|
+
writtenSet,
|
|
223
|
+
attributedChanges,
|
|
224
|
+
scopedCommit,
|
|
225
|
+
};
|