@tamsensedev/runner 0.9.0 → 0.10.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 +1 -0
- package/dist/cli.js +628 -298
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync7 } from "fs";
|
|
5
5
|
import { homedir as homedir3 } from "os";
|
|
6
|
-
import
|
|
6
|
+
import process7 from "process";
|
|
7
7
|
|
|
8
8
|
// src/api.ts
|
|
9
9
|
var DEFAULT_SERVER = "https://my.tamsense.com";
|
|
@@ -187,7 +187,7 @@ function findNpm(nodePath, env, exists) {
|
|
|
187
187
|
}
|
|
188
188
|
return null;
|
|
189
189
|
}
|
|
190
|
-
var runProcess = (command, args, options) => new Promise((
|
|
190
|
+
var runProcess = (command, args, options) => new Promise((resolve5, reject) => {
|
|
191
191
|
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
192
192
|
const tail = [];
|
|
193
193
|
for (const stream of [child.stdout, child.stderr]) {
|
|
@@ -201,7 +201,7 @@ var runProcess = (command, args, options) => new Promise((resolve3, reject) => {
|
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
203
|
child.on("error", reject);
|
|
204
|
-
child.on("close", (code) =>
|
|
204
|
+
child.on("close", (code) => resolve5({ code: code ?? 1, output: tail.join("\n") }));
|
|
205
205
|
});
|
|
206
206
|
function failureText(what, result) {
|
|
207
207
|
if (OFFLINE_PATTERN.test(result.output)) {
|
|
@@ -314,8 +314,8 @@ async function probeCapabilities(stop) {
|
|
|
314
314
|
stop?.throwIfAborted();
|
|
315
315
|
let release = () => {
|
|
316
316
|
};
|
|
317
|
-
const released = new Promise((
|
|
318
|
-
release =
|
|
317
|
+
const released = new Promise((resolve5) => {
|
|
318
|
+
release = resolve5;
|
|
319
319
|
});
|
|
320
320
|
const nothing = {
|
|
321
321
|
[Symbol.asyncIterator]: () => ({
|
|
@@ -450,6 +450,10 @@ var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
|
|
|
450
450
|
var IDENTITY_PATH = "/api/runner/me";
|
|
451
451
|
var NEXT_RUN_PATH = "/api/runner/runs/next";
|
|
452
452
|
var CAPABILITIES_PATH = "/api/runner/me/capabilities";
|
|
453
|
+
var FOLDER_CHECKS_PATH = "/api/runner/folders/checks";
|
|
454
|
+
function folderCheckPath(folderId) {
|
|
455
|
+
return `/api/runner/folders/${folderId}`;
|
|
456
|
+
}
|
|
453
457
|
function runPath(runId, action) {
|
|
454
458
|
return `/api/runner/runs/${runId}/${action}`;
|
|
455
459
|
}
|
|
@@ -758,8 +762,8 @@ function inputChannel(options = {}) {
|
|
|
758
762
|
}
|
|
759
763
|
const next = queue.shift();
|
|
760
764
|
if (!next) {
|
|
761
|
-
await new Promise((
|
|
762
|
-
wake =
|
|
765
|
+
await new Promise((resolve5) => {
|
|
766
|
+
wake = resolve5;
|
|
763
767
|
});
|
|
764
768
|
continue;
|
|
765
769
|
}
|
|
@@ -819,9 +823,9 @@ function inputChannel(options = {}) {
|
|
|
819
823
|
onSwitch(apply) {
|
|
820
824
|
applySwitch2 = apply;
|
|
821
825
|
if (waitingSwitch) {
|
|
822
|
-
const { change, resolve:
|
|
826
|
+
const { change, resolve: resolve5 } = waitingSwitch;
|
|
823
827
|
waitingSwitch = null;
|
|
824
|
-
void apply(change).then(() =>
|
|
828
|
+
void apply(change).then(() => resolve5(true), () => resolve5(false));
|
|
825
829
|
}
|
|
826
830
|
},
|
|
827
831
|
onStopHelper(stop) {
|
|
@@ -882,8 +886,8 @@ function inputChannel(options = {}) {
|
|
|
882
886
|
const earlier = waitingSwitch;
|
|
883
887
|
earlier?.resolve(false);
|
|
884
888
|
const merged = { model: change.model ?? earlier?.change.model ?? null, effort: change.effort ?? earlier?.change.effort ?? null };
|
|
885
|
-
return new Promise((
|
|
886
|
-
waitingSwitch = { change: merged, resolve:
|
|
889
|
+
return new Promise((resolve5) => {
|
|
890
|
+
waitingSwitch = { change: merged, resolve: resolve5 };
|
|
887
891
|
});
|
|
888
892
|
}
|
|
889
893
|
};
|
|
@@ -1087,9 +1091,515 @@ function askPermission(tools, lastWords, folder) {
|
|
|
1087
1091
|
}
|
|
1088
1092
|
var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
|
|
1089
1093
|
|
|
1094
|
+
// src/localWorkspace.ts
|
|
1095
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1096
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
1097
|
+
|
|
1098
|
+
// src/folders.ts
|
|
1099
|
+
import { existsSync as existsSync2, realpathSync, statSync } from "fs";
|
|
1100
|
+
import { basename, isAbsolute, join as join2, relative, resolve } from "path";
|
|
1101
|
+
import process3 from "process";
|
|
1102
|
+
|
|
1103
|
+
// src/git.ts
|
|
1104
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
1105
|
+
import { spawn as spawn2 } from "child_process";
|
|
1106
|
+
import process2 from "process";
|
|
1107
|
+
var GITHUB_ORIGIN = "https://github.com/";
|
|
1108
|
+
function githubAuthEnv(token) {
|
|
1109
|
+
const credentials = Buffer2.from(`x-access-token:${token}`).toString("base64");
|
|
1110
|
+
return {
|
|
1111
|
+
GIT_CONFIG_COUNT: "1",
|
|
1112
|
+
GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
|
|
1113
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
|
|
1114
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
function personalGitEnv(env = process2.env) {
|
|
1118
|
+
return { GIT_TERMINAL_PROMPT: "0", ...env.GIT_SSH_COMMAND ? {} : { GIT_SSH_COMMAND: "ssh -o BatchMode=yes" } };
|
|
1119
|
+
}
|
|
1120
|
+
function runGit(args, options) {
|
|
1121
|
+
const env = { ...process2.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
|
|
1122
|
+
return new Promise((resolve5, reject) => {
|
|
1123
|
+
const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"], ...options.timeoutMs ? { timeout: options.timeoutMs } : {} });
|
|
1124
|
+
let stdout = "";
|
|
1125
|
+
let stderr = "";
|
|
1126
|
+
child.stdout.on("data", (chunk) => {
|
|
1127
|
+
stdout += chunk.toString();
|
|
1128
|
+
});
|
|
1129
|
+
child.stderr.on("data", (chunk) => {
|
|
1130
|
+
stderr += chunk.toString();
|
|
1131
|
+
});
|
|
1132
|
+
child.on("error", reject);
|
|
1133
|
+
child.on("close", (code) => resolve5({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
async function git(args, options) {
|
|
1137
|
+
const result = await runGit(args, options);
|
|
1138
|
+
if (result.code !== 0) {
|
|
1139
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
|
|
1140
|
+
}
|
|
1141
|
+
return result.stdout;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/folders.ts
|
|
1145
|
+
var FIRST_REMOTE = "origin";
|
|
1146
|
+
var USUAL_BASES = ["main", "master"];
|
|
1147
|
+
var CHECKS_INTERVAL_MS = 5e3;
|
|
1148
|
+
var WEB_REMOTE = /^[a-z][\w+.-]*:\/\//i;
|
|
1149
|
+
var NETWORK_REMOTE = /^(?!file:)[a-z][\w+.-]*:\/\/|^[^/@]+@[^/:]+:/i;
|
|
1150
|
+
function problem(text2) {
|
|
1151
|
+
return { ok: false, problem: text2 };
|
|
1152
|
+
}
|
|
1153
|
+
async function gitText(args, cwd) {
|
|
1154
|
+
const result = await runGit(args, { cwd });
|
|
1155
|
+
return result.code === 0 && result.stdout ? result.stdout : null;
|
|
1156
|
+
}
|
|
1157
|
+
function expandHome(path, home) {
|
|
1158
|
+
const trimmed = path.trim();
|
|
1159
|
+
return trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\") ? join2(home, trimmed.slice(1)) : trimmed;
|
|
1160
|
+
}
|
|
1161
|
+
function remoteWithoutSecrets(url) {
|
|
1162
|
+
if (!WEB_REMOTE.test(url)) {
|
|
1163
|
+
return url;
|
|
1164
|
+
}
|
|
1165
|
+
try {
|
|
1166
|
+
const parsed = new URL(url);
|
|
1167
|
+
if (parsed.username || parsed.password) {
|
|
1168
|
+
parsed.username = "";
|
|
1169
|
+
parsed.password = "";
|
|
1170
|
+
}
|
|
1171
|
+
return parsed.toString().replace(/\/$/, "");
|
|
1172
|
+
} catch {
|
|
1173
|
+
return url;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
function repositoryName(remoteUrl, folder) {
|
|
1177
|
+
const parts = remoteUrl && NETWORK_REMOTE.test(remoteUrl) ? remoteUrl.replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).filter(Boolean) : [];
|
|
1178
|
+
return parts.length >= 3 ? `${parts.at(-2)}/${parts.at(-1)}` : basename(folder);
|
|
1179
|
+
}
|
|
1180
|
+
async function remoteOf(folder) {
|
|
1181
|
+
const remotes = (await gitText(["remote"], folder))?.split("\n").map((line) => line.trim()).filter(Boolean) ?? [];
|
|
1182
|
+
return remotes.includes(FIRST_REMOTE) ? FIRST_REMOTE : remotes[0] ?? null;
|
|
1183
|
+
}
|
|
1184
|
+
async function hasRef(folder, ref) {
|
|
1185
|
+
return (await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: folder })).code === 0;
|
|
1186
|
+
}
|
|
1187
|
+
async function baseBranchOf(folder, remote, current) {
|
|
1188
|
+
const remoteHead = remote ? await gitText(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], folder) : null;
|
|
1189
|
+
if (remoteHead?.startsWith(`${remote}/`)) {
|
|
1190
|
+
return remoteHead.slice(remote.length + 1);
|
|
1191
|
+
}
|
|
1192
|
+
for (const name of USUAL_BASES) {
|
|
1193
|
+
if (await hasRef(folder, `refs/heads/${name}`) || remote && await hasRef(folder, `refs/remotes/${remote}/${name}`)) {
|
|
1194
|
+
return name;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return current;
|
|
1198
|
+
}
|
|
1199
|
+
function samePath(first, second) {
|
|
1200
|
+
const [a, b] = [realpathSync.native(first), realpathSync.native(second)];
|
|
1201
|
+
return process3.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
1202
|
+
}
|
|
1203
|
+
function isInside(path, folder) {
|
|
1204
|
+
const between = relative(resolve(folder), resolve(path));
|
|
1205
|
+
return between === "" || !between.startsWith("..") && !isAbsolute(between);
|
|
1206
|
+
}
|
|
1207
|
+
async function inspectFolder(path, home) {
|
|
1208
|
+
const folder = expandHome(path, home);
|
|
1209
|
+
if (!isAbsolute(folder)) {
|
|
1210
|
+
return problem("Enter the full path of the folder, for example /Users/anna/code/shop or C:\\code\\shop.");
|
|
1211
|
+
}
|
|
1212
|
+
if (!existsSync2(folder) || !statSync(folder).isDirectory()) {
|
|
1213
|
+
return problem(`There is no folder ${folder} on this computer.`);
|
|
1214
|
+
}
|
|
1215
|
+
if (isInside(folder, join2(home, ".tamsense"))) {
|
|
1216
|
+
return problem("This is a TAMsense work folder. Choose your own copy of the repository.");
|
|
1217
|
+
}
|
|
1218
|
+
const top = await gitText(["rev-parse", "--show-toplevel"], folder);
|
|
1219
|
+
if (!top) {
|
|
1220
|
+
return problem(`${folder} is not a git repository. Choose the folder of a cloned repository, or run git init there.`);
|
|
1221
|
+
}
|
|
1222
|
+
if (!samePath(top, folder)) {
|
|
1223
|
+
return problem(`${folder} is inside the repository ${resolve(top)}. Add the top folder of the repository instead.`);
|
|
1224
|
+
}
|
|
1225
|
+
if (!await hasRef(folder, "HEAD")) {
|
|
1226
|
+
return problem("The repository has no commits yet. Make the first commit, then check the folder again.");
|
|
1227
|
+
}
|
|
1228
|
+
const remote = await remoteOf(folder);
|
|
1229
|
+
const remoteUrl = remote ? await gitText(["remote", "get-url", remote], folder) : null;
|
|
1230
|
+
const currentBranch = await gitText(["symbolic-ref", "--quiet", "--short", "HEAD"], folder);
|
|
1231
|
+
const defaultBranch = await baseBranchOf(folder, remote, currentBranch);
|
|
1232
|
+
if (!defaultBranch) {
|
|
1233
|
+
return problem("Could not tell the main branch of the repository. Check out the main branch in this folder, then check it again.");
|
|
1234
|
+
}
|
|
1235
|
+
const safeUrl = remoteUrl && remoteWithoutSecrets(remoteUrl);
|
|
1236
|
+
return { ok: true, repository: repositoryName(safeUrl, folder), currentBranch, defaultBranch, remoteUrl: safeUrl };
|
|
1237
|
+
}
|
|
1238
|
+
function reportLine(path, report) {
|
|
1239
|
+
return report.ok ? `Checked the folder ${path}: ${report.repository}, main branch ${report.defaultBranch}.` : `The folder ${path} cannot be used: ${report.problem}`;
|
|
1240
|
+
}
|
|
1241
|
+
async function checkFolders(tools, stop) {
|
|
1242
|
+
let complained = false;
|
|
1243
|
+
while (!stop.aborted) {
|
|
1244
|
+
try {
|
|
1245
|
+
const { checks } = await tools.api.request("POST", FOLDER_CHECKS_PATH);
|
|
1246
|
+
for (const check of checks) {
|
|
1247
|
+
const report = await tools.inspect(check.path).catch((error) => problem(error instanceof Error ? error.message : String(error)));
|
|
1248
|
+
await tools.api.request("POST", folderCheckPath(check.id), report);
|
|
1249
|
+
tools.print(reportLine(check.path, report));
|
|
1250
|
+
}
|
|
1251
|
+
complained = false;
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (!complained && !stop.aborted) {
|
|
1257
|
+
tools.print(`Could not check the folders for TAMsense: ${error instanceof Error ? error.message : String(error)}`);
|
|
1258
|
+
complained = true;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
await tools.sleep(CHECKS_INTERVAL_MS, stop);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/lock.ts
|
|
1266
|
+
function keyedLock() {
|
|
1267
|
+
const tails = /* @__PURE__ */ new Map();
|
|
1268
|
+
return {
|
|
1269
|
+
async run(key, work2) {
|
|
1270
|
+
const previous = tails.get(key) ?? Promise.resolve();
|
|
1271
|
+
let release = () => {
|
|
1272
|
+
};
|
|
1273
|
+
const done2 = new Promise((resolve5) => {
|
|
1274
|
+
release = resolve5;
|
|
1275
|
+
});
|
|
1276
|
+
const tail = previous.then(() => done2);
|
|
1277
|
+
tails.set(key, tail);
|
|
1278
|
+
await previous;
|
|
1279
|
+
try {
|
|
1280
|
+
return await work2();
|
|
1281
|
+
} finally {
|
|
1282
|
+
release();
|
|
1283
|
+
if (tails.get(key) === tail) {
|
|
1284
|
+
tails.delete(key);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// src/workspace.ts
|
|
1292
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1293
|
+
import { rm as rm2 } from "fs/promises";
|
|
1294
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
1295
|
+
var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
|
|
1296
|
+
var cacheLock = keyedLock();
|
|
1297
|
+
function githubRemoteUrl(fullName) {
|
|
1298
|
+
return `https://github.com/${fullName}.git`;
|
|
1299
|
+
}
|
|
1300
|
+
function tamsenseFolder(home, ...parts) {
|
|
1301
|
+
return join3(home, ".tamsense", ...parts);
|
|
1302
|
+
}
|
|
1303
|
+
function folderNames(repositories) {
|
|
1304
|
+
const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
|
|
1305
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1306
|
+
for (const repository of repositories) {
|
|
1307
|
+
counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
|
|
1308
|
+
}
|
|
1309
|
+
return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
|
|
1310
|
+
}
|
|
1311
|
+
async function refreshCache(cache, repository, token, tools) {
|
|
1312
|
+
if (existsSync3(join3(cache, ".git"))) {
|
|
1313
|
+
await git(["fetch", "--prune", "origin"], { cwd: cache, token });
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
|
|
1317
|
+
}
|
|
1318
|
+
async function removeWorktree(cache, path) {
|
|
1319
|
+
await runGit(["worktree", "remove", "--force", path], { cwd: cache });
|
|
1320
|
+
await rm2(path, { recursive: true, force: true });
|
|
1321
|
+
await git(["worktree", "prune"], { cwd: cache });
|
|
1322
|
+
}
|
|
1323
|
+
function stepFolder(job) {
|
|
1324
|
+
const task = `task-${job.taskId}`;
|
|
1325
|
+
return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
|
|
1326
|
+
}
|
|
1327
|
+
function isReadOnlyCheck(job) {
|
|
1328
|
+
return job.stage.kind === "review" || job.stage.kind === "test";
|
|
1329
|
+
}
|
|
1330
|
+
function branchHolders(porcelain, branch) {
|
|
1331
|
+
const holders = [];
|
|
1332
|
+
let path = null;
|
|
1333
|
+
for (const line of porcelain.split("\n").map((text2) => text2.trim())) {
|
|
1334
|
+
if (line.startsWith("worktree ")) {
|
|
1335
|
+
path = line.slice("worktree ".length);
|
|
1336
|
+
} else if (path && line === `branch refs/heads/${branch}`) {
|
|
1337
|
+
holders.push(path);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return holders;
|
|
1341
|
+
}
|
|
1342
|
+
async function releaseBranch(cache, branch, keep) {
|
|
1343
|
+
const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
|
|
1344
|
+
for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve2(holder) !== resolve2(keep))) {
|
|
1345
|
+
await removeWorktree(cache, path);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
async function baseRef(cache, job, repository) {
|
|
1349
|
+
const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
|
|
1350
|
+
return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
|
|
1351
|
+
}
|
|
1352
|
+
async function keptBase(cache, path, job, repository) {
|
|
1353
|
+
if (!job.keepWorkspace || !existsSync3(join3(path, ".git"))) {
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
|
|
1357
|
+
if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
|
|
1361
|
+
}
|
|
1362
|
+
function taskFolder(home, job) {
|
|
1363
|
+
return tamsenseFolder(home, "work", ...stepFolder(job));
|
|
1364
|
+
}
|
|
1365
|
+
async function prepareWorkspace(job, token, tools) {
|
|
1366
|
+
const root = taskFolder(tools.home, job);
|
|
1367
|
+
const folders = folderNames(job.repositories);
|
|
1368
|
+
const repositories = [];
|
|
1369
|
+
for (const [index, repository] of job.repositories.entries()) {
|
|
1370
|
+
const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
|
|
1371
|
+
const folder = folders[index] ?? repository.fullName;
|
|
1372
|
+
const path = join3(root, folder);
|
|
1373
|
+
const baseSha = await cacheLock.run(repository.fullName, async () => {
|
|
1374
|
+
await refreshCache(cache, repository, token, tools);
|
|
1375
|
+
const kept = await keptBase(cache, path, job, repository);
|
|
1376
|
+
if (kept) {
|
|
1377
|
+
return kept;
|
|
1378
|
+
}
|
|
1379
|
+
await removeWorktree(cache, path);
|
|
1380
|
+
const base = await baseRef(cache, job, repository);
|
|
1381
|
+
if (isReadOnlyCheck(job)) {
|
|
1382
|
+
await git(["worktree", "add", "--force", "--detach", path, base], { cwd: cache });
|
|
1383
|
+
} else {
|
|
1384
|
+
await releaseBranch(cache, job.branch, path);
|
|
1385
|
+
await git(["worktree", "add", "--force", "-B", job.branch, path, base], { cwd: cache });
|
|
1386
|
+
}
|
|
1387
|
+
return git(["rev-parse", "HEAD"], { cwd: path });
|
|
1388
|
+
});
|
|
1389
|
+
repositories.push({ fullName: repository.fullName, folder, path, baseSha });
|
|
1390
|
+
}
|
|
1391
|
+
return { root, repositories };
|
|
1392
|
+
}
|
|
1393
|
+
async function commitIdentityEnv(home) {
|
|
1394
|
+
const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
|
|
1395
|
+
if (configured.code === 0 && configured.stdout) {
|
|
1396
|
+
return {};
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
|
|
1400
|
+
GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
|
|
1401
|
+
GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
|
|
1402
|
+
GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
async function publishWorkspace(workspace, job, token, identity) {
|
|
1406
|
+
const pushed = [];
|
|
1407
|
+
for (const repository of workspace.repositories) {
|
|
1408
|
+
const options = { cwd: repository.path, env: identity };
|
|
1409
|
+
const sha = await cacheLock.run(repository.fullName, async () => {
|
|
1410
|
+
await git(["add", "--all"], options);
|
|
1411
|
+
const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
|
|
1412
|
+
if (hasStaged) {
|
|
1413
|
+
await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
|
|
1414
|
+
}
|
|
1415
|
+
const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
|
|
1416
|
+
if (newCommits === 0) {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
|
|
1420
|
+
return git(["rev-parse", "HEAD"], options);
|
|
1421
|
+
});
|
|
1422
|
+
if (sha) {
|
|
1423
|
+
pushed.push({ repository: repository.fullName, branch: job.branch, sha });
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return pushed;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// src/localWorkspace.ts
|
|
1430
|
+
var NETWORK_TIMEOUT_MS = 12e4;
|
|
1431
|
+
var sourceLock = keyedLock();
|
|
1432
|
+
function isLocalJob(job) {
|
|
1433
|
+
return job.workspace === "local";
|
|
1434
|
+
}
|
|
1435
|
+
function localBranchLine(branch) {
|
|
1436
|
+
const local = branch.local;
|
|
1437
|
+
if (!local) {
|
|
1438
|
+
return `Pushed ${branch.branch} in ${branch.repository}.`;
|
|
1439
|
+
}
|
|
1440
|
+
const where = `Branch ${branch.branch} has ${local.commits} commit(s) in ${local.folder}`;
|
|
1441
|
+
if (local.pushProblem) {
|
|
1442
|
+
return `${where}. It was not pushed: ${local.pushProblem}`;
|
|
1443
|
+
}
|
|
1444
|
+
return local.remoteUrl ? `${where}, pushed to ${local.remoteUrl}.` : `${where}.`;
|
|
1445
|
+
}
|
|
1446
|
+
function readsOnly(job) {
|
|
1447
|
+
return isReadOnlyCheck(job) || job.stage.kind === "interview" || job.stage.kind === "spec";
|
|
1448
|
+
}
|
|
1449
|
+
async function isAncestor(folder, older, newer) {
|
|
1450
|
+
return (await runGit(["merge-base", "--is-ancestor", older, newer], { cwd: folder })).code === 0;
|
|
1451
|
+
}
|
|
1452
|
+
async function fetchRemote(source, remote, print2) {
|
|
1453
|
+
const fetched = await runGit(["fetch", "--prune", remote], { cwd: source, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
|
|
1454
|
+
if (fetched.code !== 0) {
|
|
1455
|
+
print2(`Could not fetch from ${remote} in ${source}, going on with what the folder has: ${fetched.stderr || `exit code ${fetched.code}`}`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
async function baseBranchRef(source, branch, remote) {
|
|
1459
|
+
const own = await hasRef(source, `refs/heads/${branch}`) ? branch : null;
|
|
1460
|
+
const fetched = remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
|
|
1461
|
+
if (own && fetched) {
|
|
1462
|
+
return await isAncestor(source, own, fetched) ? fetched : own;
|
|
1463
|
+
}
|
|
1464
|
+
const found = own ?? fetched;
|
|
1465
|
+
if (!found) {
|
|
1466
|
+
throw new Error(`The main branch ${branch} is not in ${source}. Check the folder again in the board settings, on the Code tab.`);
|
|
1467
|
+
}
|
|
1468
|
+
return found;
|
|
1469
|
+
}
|
|
1470
|
+
async function taskBranchRef(source, branch, remote, base) {
|
|
1471
|
+
if (await hasRef(source, `refs/heads/${branch}`) && Number(await git(["rev-list", "--count", `${base}..${branch}`], { cwd: source })) > 0) {
|
|
1472
|
+
return branch;
|
|
1473
|
+
}
|
|
1474
|
+
return remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
|
|
1475
|
+
}
|
|
1476
|
+
async function startRef(source, job, local) {
|
|
1477
|
+
const base = await baseBranchRef(source, local.baseBranch, local.remote);
|
|
1478
|
+
const task = await taskBranchRef(source, job.branch, local.remote, base);
|
|
1479
|
+
if (task || !isReadOnlyCheck(job)) {
|
|
1480
|
+
return task ?? base;
|
|
1481
|
+
}
|
|
1482
|
+
const where = local.remote ? `${source} or on its remote ${local.remote}` : source;
|
|
1483
|
+
throw new Error(`Branch ${job.branch} is not in ${where}. It was made on another computer: push it to a remote this folder can fetch, or run this check on that computer.`);
|
|
1484
|
+
}
|
|
1485
|
+
async function releaseBranch2(source, branch, keep, home) {
|
|
1486
|
+
const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: source });
|
|
1487
|
+
const work2 = tamsenseFolder(home, "work");
|
|
1488
|
+
for (const holder of branchHolders(listed.stdout, branch).filter((path) => resolve3(path) !== resolve3(keep))) {
|
|
1489
|
+
if (!isInside(holder, work2)) {
|
|
1490
|
+
throw new Error(`Branch ${branch} is checked out in ${resolve3(holder)}. Switch that folder to another branch, then start the step again.`);
|
|
1491
|
+
}
|
|
1492
|
+
await removeWorktree(source, holder);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async function keptBase2(path, job) {
|
|
1496
|
+
if (!job.keepWorkspace || !existsSync4(join4(path, ".git"))) {
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
|
|
1500
|
+
if (branch.code !== 0 || branch.stdout !== (readsOnly(job) ? "HEAD" : job.branch)) {
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
return git(["rev-parse", "HEAD"], { cwd: path });
|
|
1504
|
+
}
|
|
1505
|
+
function sourceOf(repository) {
|
|
1506
|
+
if (!repository.folder) {
|
|
1507
|
+
throw new Error(`TAMsense did not say which folder holds ${repository.fullName} on this computer.`);
|
|
1508
|
+
}
|
|
1509
|
+
return repository.folder;
|
|
1510
|
+
}
|
|
1511
|
+
async function prepareRepository(job, repository, path, tools) {
|
|
1512
|
+
const source = sourceOf(repository);
|
|
1513
|
+
if ((await runGit(["rev-parse", "--show-toplevel"], { cwd: source }).catch(() => null))?.code !== 0) {
|
|
1514
|
+
throw new Error(`${source} is no longer a git repository on this computer. Check the folder again in the board settings, on the Code tab.`);
|
|
1515
|
+
}
|
|
1516
|
+
const local = { source, remote: await remoteOf(source), baseBranch: repository.defaultBranch };
|
|
1517
|
+
if (local.remote) {
|
|
1518
|
+
await fetchRemote(source, local.remote, tools.print);
|
|
1519
|
+
}
|
|
1520
|
+
const kept = await keptBase2(path, job);
|
|
1521
|
+
if (kept) {
|
|
1522
|
+
return { baseSha: kept, local };
|
|
1523
|
+
}
|
|
1524
|
+
await runGit(["worktree", "prune"], { cwd: source });
|
|
1525
|
+
if (existsSync4(path)) {
|
|
1526
|
+
await removeWorktree(source, path);
|
|
1527
|
+
}
|
|
1528
|
+
const start = await startRef(source, job, local);
|
|
1529
|
+
if (readsOnly(job)) {
|
|
1530
|
+
await git(["worktree", "add", "--detach", path, start], { cwd: source });
|
|
1531
|
+
} else {
|
|
1532
|
+
await releaseBranch2(source, job.branch, path, tools.home);
|
|
1533
|
+
await git(["worktree", "add", "-B", job.branch, path, start], { cwd: source });
|
|
1534
|
+
}
|
|
1535
|
+
return { baseSha: await git(["rev-parse", "HEAD"], { cwd: path }), local };
|
|
1536
|
+
}
|
|
1537
|
+
async function prepareLocalWorkspace(job, tools) {
|
|
1538
|
+
const root = taskFolder(tools.home, job);
|
|
1539
|
+
const folders = folderNames(job.repositories);
|
|
1540
|
+
const repositories = [];
|
|
1541
|
+
for (const [index, repository] of job.repositories.entries()) {
|
|
1542
|
+
const folder = folders[index] ?? repository.fullName;
|
|
1543
|
+
const path = join4(root, folder);
|
|
1544
|
+
const prepared = await sourceLock.run(resolve3(sourceOf(repository)), () => prepareRepository(job, repository, path, tools));
|
|
1545
|
+
repositories.push({ fullName: repository.fullName, folder, path, baseSha: prepared.baseSha, local: prepared.local });
|
|
1546
|
+
}
|
|
1547
|
+
return { root, repositories };
|
|
1548
|
+
}
|
|
1549
|
+
async function pushBranch(path, local, branch) {
|
|
1550
|
+
if (!local.remote) {
|
|
1551
|
+
return { remoteUrl: null, pushProblem: null };
|
|
1552
|
+
}
|
|
1553
|
+
if (branch === local.baseBranch) {
|
|
1554
|
+
return { remoteUrl: null, pushProblem: `TAMsense never pushes the main branch ${branch}.` };
|
|
1555
|
+
}
|
|
1556
|
+
const pushed = await runGit(["push", local.remote, `HEAD:refs/heads/${branch}`], { cwd: path, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
|
|
1557
|
+
if (pushed.code !== 0) {
|
|
1558
|
+
return { remoteUrl: null, pushProblem: pushed.stderr || `exit code ${pushed.code}` };
|
|
1559
|
+
}
|
|
1560
|
+
return { remoteUrl: await git(["remote", "get-url", local.remote], { cwd: path }), pushProblem: null };
|
|
1561
|
+
}
|
|
1562
|
+
async function commitAndPush(repository, local, job, identity) {
|
|
1563
|
+
const options = { cwd: repository.path, env: identity };
|
|
1564
|
+
await git(["add", "--all"], options);
|
|
1565
|
+
if ((await runGit(["diff", "--cached", "--quiet"], options)).code === 1) {
|
|
1566
|
+
await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
|
|
1567
|
+
}
|
|
1568
|
+
if (Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options)) === 0) {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
const base = await baseBranchRef(repository.path, local.baseBranch, local.remote);
|
|
1572
|
+
const commits = Number(await git(["rev-list", "--count", `${base}..HEAD`], options));
|
|
1573
|
+
const sent2 = await pushBranch(repository.path, local, job.branch);
|
|
1574
|
+
return { sha: await git(["rev-parse", "HEAD"], options), commits, ...sent2 };
|
|
1575
|
+
}
|
|
1576
|
+
async function publishLocalWorkspace(workspace, job, identity) {
|
|
1577
|
+
const pushed = [];
|
|
1578
|
+
for (const repository of workspace.repositories) {
|
|
1579
|
+
const local = repository.local;
|
|
1580
|
+
if (!local) {
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
const branch = await sourceLock.run(resolve3(local.source), async () => {
|
|
1584
|
+
const published = await commitAndPush(repository, local, job, identity);
|
|
1585
|
+
await runGit(["checkout", "--quiet", "--detach"], { cwd: repository.path });
|
|
1586
|
+
return published;
|
|
1587
|
+
});
|
|
1588
|
+
if (branch) {
|
|
1589
|
+
pushed.push({
|
|
1590
|
+
repository: repository.fullName,
|
|
1591
|
+
branch: job.branch,
|
|
1592
|
+
sha: branch.sha,
|
|
1593
|
+
local: { folder: local.source, baseBranch: local.baseBranch, commits: branch.commits, remoteUrl: branch.remoteUrl && remoteWithoutSecrets(branch.remoteUrl), pushProblem: branch.pushProblem }
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return pushed;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1090
1600
|
// src/results.ts
|
|
1091
|
-
import { mkdir as mkdir2, readFile as readFile2, rm as
|
|
1092
|
-
import { basename, extname, isAbsolute, join as
|
|
1601
|
+
import { mkdir as mkdir2, readFile as readFile2, rm as rm3, stat, writeFile as writeFile2 } from "fs/promises";
|
|
1602
|
+
import { basename as basename2, extname, isAbsolute as isAbsolute2, join as join5, resolve as resolve4 } from "path";
|
|
1093
1603
|
import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
|
|
1094
1604
|
import { z as z2 } from "zod";
|
|
1095
1605
|
var SAVE_DOCUMENT_TOOL_NAME = "save_result_document";
|
|
@@ -1151,7 +1661,7 @@ function linksText(links) {
|
|
|
1151
1661
|
}
|
|
1152
1662
|
async function writeResults(folder, job, api) {
|
|
1153
1663
|
const results = job.results ?? [];
|
|
1154
|
-
await
|
|
1664
|
+
await rm3(folder, { recursive: true, force: true });
|
|
1155
1665
|
if (results.length === 0) {
|
|
1156
1666
|
return [];
|
|
1157
1667
|
}
|
|
@@ -1164,24 +1674,24 @@ async function writeResults(folder, job, api) {
|
|
|
1164
1674
|
if (result.kind === "file") {
|
|
1165
1675
|
try {
|
|
1166
1676
|
const file = await api.request("GET", resultFilePath(job.runId, result.id));
|
|
1167
|
-
await writeFile2(
|
|
1677
|
+
await writeFile2(join5(folder, name), Buffer.from(file.data, "base64"));
|
|
1168
1678
|
} catch (error) {
|
|
1169
1679
|
missing.push(`${result.title}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1170
1680
|
continue;
|
|
1171
1681
|
}
|
|
1172
1682
|
} else {
|
|
1173
|
-
await writeFile2(
|
|
1683
|
+
await writeFile2(join5(folder, name), result.content ?? "");
|
|
1174
1684
|
}
|
|
1175
1685
|
lines.push(`${RESULTS_FOLDER}/${name}: ${result.title}${result.version ? `, version ${result.version}` : ""}`);
|
|
1176
1686
|
}
|
|
1177
1687
|
const links = results.filter((item) => item.kind === "link");
|
|
1178
1688
|
if (links.length > 0) {
|
|
1179
|
-
await writeFile2(
|
|
1689
|
+
await writeFile2(join5(folder, "links.md"), `${linksText(links)}
|
|
1180
1690
|
`);
|
|
1181
1691
|
lines.push(`${RESULTS_FOLDER}/links.md: ${links.length} link(s)`);
|
|
1182
1692
|
}
|
|
1183
1693
|
if (missing.length > 0) {
|
|
1184
|
-
await writeFile2(
|
|
1694
|
+
await writeFile2(join5(folder, "notes.md"), `These files could not be downloaded:
|
|
1185
1695
|
${missing.map((line) => `- ${line}`).join("\n")}
|
|
1186
1696
|
`);
|
|
1187
1697
|
}
|
|
@@ -1189,13 +1699,13 @@ ${missing.map((line) => `- ${line}`).join("\n")}
|
|
|
1189
1699
|
}
|
|
1190
1700
|
async function prepareFolder(job, root, api) {
|
|
1191
1701
|
if (!job.keepWorkspace) {
|
|
1192
|
-
await
|
|
1702
|
+
await rm3(root, { recursive: true, force: true });
|
|
1193
1703
|
}
|
|
1194
1704
|
await mkdir2(root, { recursive: true });
|
|
1195
|
-
return { root, repositories: [], results: await writeResults(
|
|
1705
|
+
return { root, repositories: [], results: await writeResults(join5(root, RESULTS_FOLDER), job, api) };
|
|
1196
1706
|
}
|
|
1197
1707
|
async function readResultFile(root, path, title) {
|
|
1198
|
-
const full =
|
|
1708
|
+
const full = isAbsolute2(path) ? path : resolve4(root, path);
|
|
1199
1709
|
const info = await stat(full).catch(() => null);
|
|
1200
1710
|
if (!info?.isFile()) {
|
|
1201
1711
|
return `There is no file at ${full}.`;
|
|
@@ -1203,7 +1713,7 @@ async function readResultFile(root, path, title) {
|
|
|
1203
1713
|
if (info.size > MAX_RESULT_FILE_BYTES) {
|
|
1204
1714
|
return `The file is larger than ${MAX_RESULT_FILE_BYTES / 1024 / 1024} MB. Attach a smaller one or split it.`;
|
|
1205
1715
|
}
|
|
1206
|
-
const name =
|
|
1716
|
+
const name = basename2(full);
|
|
1207
1717
|
return { name, title: title.trim() || name, contentType: TYPES_BY_EXTENSION[extname(full).toLowerCase()] ?? "application/octet-stream", data: (await readFile2(full)).toString("base64") };
|
|
1208
1718
|
}
|
|
1209
1719
|
function reply(text2, isError = false) {
|
|
@@ -1376,209 +1886,6 @@ function specPromptText(spec) {
|
|
|
1376
1886
|
].filter(Boolean).join("\n\n");
|
|
1377
1887
|
}
|
|
1378
1888
|
|
|
1379
|
-
// src/workspace.ts
|
|
1380
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1381
|
-
import { rm as rm3 } from "fs/promises";
|
|
1382
|
-
import { join as join3, resolve as resolve2 } from "path";
|
|
1383
|
-
|
|
1384
|
-
// src/git.ts
|
|
1385
|
-
import { Buffer as Buffer2 } from "buffer";
|
|
1386
|
-
import { spawn as spawn2 } from "child_process";
|
|
1387
|
-
var GITHUB_ORIGIN = "https://github.com/";
|
|
1388
|
-
function githubAuthEnv(token) {
|
|
1389
|
-
const credentials = Buffer2.from(`x-access-token:${token}`).toString("base64");
|
|
1390
|
-
return {
|
|
1391
|
-
GIT_CONFIG_COUNT: "1",
|
|
1392
|
-
GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
|
|
1393
|
-
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
|
|
1394
|
-
GIT_TERMINAL_PROMPT: "0"
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
function runGit(args, options) {
|
|
1398
|
-
const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
|
|
1399
|
-
return new Promise((resolve3, reject) => {
|
|
1400
|
-
const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1401
|
-
let stdout = "";
|
|
1402
|
-
let stderr = "";
|
|
1403
|
-
child.stdout.on("data", (chunk) => {
|
|
1404
|
-
stdout += chunk.toString();
|
|
1405
|
-
});
|
|
1406
|
-
child.stderr.on("data", (chunk) => {
|
|
1407
|
-
stderr += chunk.toString();
|
|
1408
|
-
});
|
|
1409
|
-
child.on("error", reject);
|
|
1410
|
-
child.on("close", (code) => resolve3({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
async function git(args, options) {
|
|
1414
|
-
const result = await runGit(args, options);
|
|
1415
|
-
if (result.code !== 0) {
|
|
1416
|
-
throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
|
|
1417
|
-
}
|
|
1418
|
-
return result.stdout;
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
// src/lock.ts
|
|
1422
|
-
function keyedLock() {
|
|
1423
|
-
const tails = /* @__PURE__ */ new Map();
|
|
1424
|
-
return {
|
|
1425
|
-
async run(key, work2) {
|
|
1426
|
-
const previous = tails.get(key) ?? Promise.resolve();
|
|
1427
|
-
let release = () => {
|
|
1428
|
-
};
|
|
1429
|
-
const done2 = new Promise((resolve3) => {
|
|
1430
|
-
release = resolve3;
|
|
1431
|
-
});
|
|
1432
|
-
const tail = previous.then(() => done2);
|
|
1433
|
-
tails.set(key, tail);
|
|
1434
|
-
await previous;
|
|
1435
|
-
try {
|
|
1436
|
-
return await work2();
|
|
1437
|
-
} finally {
|
|
1438
|
-
release();
|
|
1439
|
-
if (tails.get(key) === tail) {
|
|
1440
|
-
tails.delete(key);
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
};
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
// src/workspace.ts
|
|
1448
|
-
var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
|
|
1449
|
-
var cacheLock = keyedLock();
|
|
1450
|
-
function githubRemoteUrl(fullName) {
|
|
1451
|
-
return `https://github.com/${fullName}.git`;
|
|
1452
|
-
}
|
|
1453
|
-
function tamsenseFolder(home, ...parts) {
|
|
1454
|
-
return join3(home, ".tamsense", ...parts);
|
|
1455
|
-
}
|
|
1456
|
-
function folderNames(repositories) {
|
|
1457
|
-
const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
|
|
1458
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1459
|
-
for (const repository of repositories) {
|
|
1460
|
-
counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
|
|
1461
|
-
}
|
|
1462
|
-
return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
|
|
1463
|
-
}
|
|
1464
|
-
async function refreshCache(cache, repository, token, tools) {
|
|
1465
|
-
if (existsSync2(join3(cache, ".git"))) {
|
|
1466
|
-
await git(["fetch", "--prune", "origin"], { cwd: cache, token });
|
|
1467
|
-
return;
|
|
1468
|
-
}
|
|
1469
|
-
await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
|
|
1470
|
-
}
|
|
1471
|
-
async function removeWorktree(cache, path) {
|
|
1472
|
-
await runGit(["worktree", "remove", "--force", path], { cwd: cache });
|
|
1473
|
-
await rm3(path, { recursive: true, force: true });
|
|
1474
|
-
await git(["worktree", "prune"], { cwd: cache });
|
|
1475
|
-
}
|
|
1476
|
-
function stepFolder(job) {
|
|
1477
|
-
const task = `task-${job.taskId}`;
|
|
1478
|
-
return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
|
|
1479
|
-
}
|
|
1480
|
-
function isReadOnlyCheck(job) {
|
|
1481
|
-
return job.stage.kind === "review" || job.stage.kind === "test";
|
|
1482
|
-
}
|
|
1483
|
-
function branchHolders(porcelain, branch) {
|
|
1484
|
-
const holders = [];
|
|
1485
|
-
let path = null;
|
|
1486
|
-
for (const line of porcelain.split("\n").map((text2) => text2.trim())) {
|
|
1487
|
-
if (line.startsWith("worktree ")) {
|
|
1488
|
-
path = line.slice("worktree ".length);
|
|
1489
|
-
} else if (path && line === `branch refs/heads/${branch}`) {
|
|
1490
|
-
holders.push(path);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
return holders;
|
|
1494
|
-
}
|
|
1495
|
-
async function releaseBranch(cache, branch, keep) {
|
|
1496
|
-
const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
|
|
1497
|
-
for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve2(holder) !== resolve2(keep))) {
|
|
1498
|
-
await removeWorktree(cache, path);
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
async function baseRef(cache, job, repository) {
|
|
1502
|
-
const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
|
|
1503
|
-
return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
|
|
1504
|
-
}
|
|
1505
|
-
async function keptBase(cache, path, job, repository) {
|
|
1506
|
-
if (!job.keepWorkspace || !existsSync2(join3(path, ".git"))) {
|
|
1507
|
-
return null;
|
|
1508
|
-
}
|
|
1509
|
-
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
|
|
1510
|
-
if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
|
|
1511
|
-
return null;
|
|
1512
|
-
}
|
|
1513
|
-
return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
|
|
1514
|
-
}
|
|
1515
|
-
function taskFolder(home, job) {
|
|
1516
|
-
return tamsenseFolder(home, "work", ...stepFolder(job));
|
|
1517
|
-
}
|
|
1518
|
-
async function prepareWorkspace(job, token, tools) {
|
|
1519
|
-
const root = taskFolder(tools.home, job);
|
|
1520
|
-
const folders = folderNames(job.repositories);
|
|
1521
|
-
const repositories = [];
|
|
1522
|
-
for (const [index, repository] of job.repositories.entries()) {
|
|
1523
|
-
const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
|
|
1524
|
-
const folder = folders[index] ?? repository.fullName;
|
|
1525
|
-
const path = join3(root, folder);
|
|
1526
|
-
const baseSha = await cacheLock.run(repository.fullName, async () => {
|
|
1527
|
-
await refreshCache(cache, repository, token, tools);
|
|
1528
|
-
const kept = await keptBase(cache, path, job, repository);
|
|
1529
|
-
if (kept) {
|
|
1530
|
-
return kept;
|
|
1531
|
-
}
|
|
1532
|
-
await removeWorktree(cache, path);
|
|
1533
|
-
const base = await baseRef(cache, job, repository);
|
|
1534
|
-
if (isReadOnlyCheck(job)) {
|
|
1535
|
-
await git(["worktree", "add", "--force", "--detach", path, base], { cwd: cache });
|
|
1536
|
-
} else {
|
|
1537
|
-
await releaseBranch(cache, job.branch, path);
|
|
1538
|
-
await git(["worktree", "add", "--force", "-B", job.branch, path, base], { cwd: cache });
|
|
1539
|
-
}
|
|
1540
|
-
return git(["rev-parse", "HEAD"], { cwd: path });
|
|
1541
|
-
});
|
|
1542
|
-
repositories.push({ fullName: repository.fullName, folder, path, baseSha });
|
|
1543
|
-
}
|
|
1544
|
-
return { root, repositories };
|
|
1545
|
-
}
|
|
1546
|
-
async function commitIdentityEnv(home) {
|
|
1547
|
-
const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
|
|
1548
|
-
if (configured.code === 0 && configured.stdout) {
|
|
1549
|
-
return {};
|
|
1550
|
-
}
|
|
1551
|
-
return {
|
|
1552
|
-
GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
|
|
1553
|
-
GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
|
|
1554
|
-
GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
|
|
1555
|
-
GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
|
|
1556
|
-
};
|
|
1557
|
-
}
|
|
1558
|
-
async function publishWorkspace(workspace, job, token, identity) {
|
|
1559
|
-
const pushed = [];
|
|
1560
|
-
for (const repository of workspace.repositories) {
|
|
1561
|
-
const options = { cwd: repository.path, env: identity };
|
|
1562
|
-
const sha = await cacheLock.run(repository.fullName, async () => {
|
|
1563
|
-
await git(["add", "--all"], options);
|
|
1564
|
-
const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
|
|
1565
|
-
if (hasStaged) {
|
|
1566
|
-
await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
|
|
1567
|
-
}
|
|
1568
|
-
const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
|
|
1569
|
-
if (newCommits === 0) {
|
|
1570
|
-
return null;
|
|
1571
|
-
}
|
|
1572
|
-
await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
|
|
1573
|
-
return git(["rev-parse", "HEAD"], options);
|
|
1574
|
-
});
|
|
1575
|
-
if (sha) {
|
|
1576
|
-
pushed.push({ repository: repository.fullName, branch: job.branch, sha });
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
return pushed;
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
1889
|
// src/prompt.ts
|
|
1583
1890
|
var VERDICT_TOOL_NAME = "submit_verdict";
|
|
1584
1891
|
var SCREENSHOT_TOOL_NAME = "attach_screenshot";
|
|
@@ -1599,6 +1906,8 @@ var CODE_RULES = [
|
|
|
1599
1906
|
"Check your work with the project's own tools: run the tests, type checks and linters that exist, and fix what you broke.",
|
|
1600
1907
|
"Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch and opens the pull request."
|
|
1601
1908
|
];
|
|
1909
|
+
var LOCAL_COMMIT_RULE = "Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch when the folder has a remote, and the person merges it.";
|
|
1910
|
+
var LOCAL_FOLDERS_RULE = "Each repository here is a git worktree of the person's own folder on this computer. Work only here: never open, change or check out branches in the person's original folder.";
|
|
1602
1911
|
var REWORK_RULE = "This is a rework. The branch already has the earlier work and maybe the reviewer's own commits: treat the current branch as the source of truth, keep the reviewer's changes unless a comment asks otherwise, and address every comment.";
|
|
1603
1912
|
var CODE_REPORT_RULE = "End with a short report for the reviewer: what you changed, how you checked it, and anything left open.";
|
|
1604
1913
|
var READ_ONLY_CODE_RULES = [
|
|
@@ -1708,13 +2017,17 @@ function bulletList(lines) {
|
|
|
1708
2017
|
return lines.map((line) => `- ${line}`).join("\n");
|
|
1709
2018
|
}
|
|
1710
2019
|
function repositoryLines(job, workspace) {
|
|
1711
|
-
const
|
|
2020
|
+
const sourceOf2 = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
|
|
1712
2021
|
return workspace.repositories.map((repository) => {
|
|
1713
|
-
const source =
|
|
2022
|
+
const source = sourceOf2.get(repository.fullName);
|
|
1714
2023
|
const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
|
|
1715
|
-
|
|
2024
|
+
const base = repository.local ? repository.local.baseBranch : `origin/${source?.defaultBranch ?? "main"}`;
|
|
2025
|
+
return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch ${base}${pull}`;
|
|
1716
2026
|
});
|
|
1717
2027
|
}
|
|
2028
|
+
function codeRules(job) {
|
|
2029
|
+
return isLocalJob(job) ? [...CODE_RULES.slice(0, -1), LOCAL_COMMIT_RULE] : CODE_RULES;
|
|
2030
|
+
}
|
|
1718
2031
|
function stepLine(stage) {
|
|
1719
2032
|
return `This step of the board is "${stage.name}"${stage.helper ? `, and you work as the ${stage.helper}` : ""}.`;
|
|
1720
2033
|
}
|
|
@@ -1873,6 +2186,7 @@ ${JSON.stringify(job.spec)}` : null,
|
|
|
1873
2186
|
contextText(job.projectContext),
|
|
1874
2187
|
`The repositories for this task are checked out in the current folder, one subfolder each:
|
|
1875
2188
|
${bulletList(repositoryLines(job, workspace))}`,
|
|
2189
|
+
isLocalJob(job) && LOCAL_FOLDERS_RULE,
|
|
1876
2190
|
READ_ONLY_RULE,
|
|
1877
2191
|
`How to work:
|
|
1878
2192
|
${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
|
|
@@ -1948,7 +2262,7 @@ ${job.review}`,
|
|
|
1948
2262
|
].filter(Boolean).join("\n\n");
|
|
1949
2263
|
const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
|
|
1950
2264
|
const localApp = localAppRule(job, isCheck, canAsk);
|
|
1951
|
-
const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...
|
|
2265
|
+
const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...codeRules(job), ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
|
|
1952
2266
|
const instructions = [
|
|
1953
2267
|
canAsk ? ASKING_RULE : UNATTENDED_RULE,
|
|
1954
2268
|
MACHINE_RULE,
|
|
@@ -1958,6 +2272,7 @@ ${job.review}`,
|
|
|
1958
2272
|
contextText(job.projectContext),
|
|
1959
2273
|
`The repositories for this task are checked out in the current folder, one subfolder each, ${isCheck ? "at the latest commit of branch" : "on branch"} ${job.branch}:
|
|
1960
2274
|
${bulletList(repositoryLines(job, workspace))}`,
|
|
2275
|
+
isLocalJob(job) && LOCAL_FOLDERS_RULE,
|
|
1961
2276
|
`How to work:
|
|
1962
2277
|
${bulletList(rules)}`,
|
|
1963
2278
|
safetyText(job),
|
|
@@ -2526,11 +2841,11 @@ var runClaude = claudeRunner(query2);
|
|
|
2526
2841
|
// src/config.ts
|
|
2527
2842
|
import { mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
|
|
2528
2843
|
import { homedir as homedir2 } from "os";
|
|
2529
|
-
import { dirname as dirname2, join as
|
|
2844
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
2530
2845
|
var OWNER_ONLY_FILE = 384;
|
|
2531
2846
|
var OWNER_ONLY_FOLDER = 448;
|
|
2532
2847
|
function configPath(home = homedir2()) {
|
|
2533
|
-
return
|
|
2848
|
+
return join6(home, ".tamsense", "runner.json");
|
|
2534
2849
|
}
|
|
2535
2850
|
function isRecord2(value) {
|
|
2536
2851
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -2594,15 +2909,15 @@ async function login(tools, request) {
|
|
|
2594
2909
|
}
|
|
2595
2910
|
|
|
2596
2911
|
// src/recorderEnv.ts
|
|
2597
|
-
import { existsSync as
|
|
2598
|
-
import { join as
|
|
2912
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2913
|
+
import { join as join8 } from "path";
|
|
2599
2914
|
import { fileURLToPath } from "url";
|
|
2600
2915
|
|
|
2601
2916
|
// src/recorder.ts
|
|
2602
2917
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2603
2918
|
import Module, { createRequire } from "module";
|
|
2604
|
-
import { basename as
|
|
2605
|
-
import
|
|
2919
|
+
import { basename as basename3, dirname as dirname3, join as join7 } from "path";
|
|
2920
|
+
import process4 from "process";
|
|
2606
2921
|
var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
|
|
2607
2922
|
var SERVER_ENV = "TAMSENSE_SERVER";
|
|
2608
2923
|
var DEFAULT_TITLE_ENV = "TAMSENSE_RECORDING_TITLE";
|
|
@@ -2626,14 +2941,14 @@ function state() {
|
|
|
2626
2941
|
return holder[STATE_KEY];
|
|
2627
2942
|
}
|
|
2628
2943
|
function recordingsDir() {
|
|
2629
|
-
const dir =
|
|
2944
|
+
const dir = process4.env[RECORDINGS_DIR_ENV];
|
|
2630
2945
|
if (!dir) {
|
|
2631
2946
|
throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`);
|
|
2632
2947
|
}
|
|
2633
2948
|
return dir;
|
|
2634
2949
|
}
|
|
2635
2950
|
function warn(text2) {
|
|
2636
|
-
|
|
2951
|
+
process4.stderr.write(`[tamsense recorder] ${text2}
|
|
2637
2952
|
`);
|
|
2638
2953
|
}
|
|
2639
2954
|
function isTrackerIngest(url, tamsenseOrigins) {
|
|
@@ -2649,7 +2964,7 @@ function isLocalHost(hostname2) {
|
|
|
2649
2964
|
function isExternalWrite(method, url) {
|
|
2650
2965
|
return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname);
|
|
2651
2966
|
}
|
|
2652
|
-
function trackerOrigins(env =
|
|
2967
|
+
function trackerOrigins(env = process4.env) {
|
|
2653
2968
|
const origins = new Set(DEFAULT_TRACKER_ORIGINS);
|
|
2654
2969
|
try {
|
|
2655
2970
|
if (env[SERVER_ENV]) {
|
|
@@ -2673,8 +2988,8 @@ function checkCaption(caption) {
|
|
|
2673
2988
|
function clip2(text2, max) {
|
|
2674
2989
|
return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
|
|
2675
2990
|
}
|
|
2676
|
-
function automaticCaption(env =
|
|
2677
|
-
const from = script2 ? `, opened by ${
|
|
2991
|
+
function automaticCaption(env = process4.env, script2 = process4.argv[1]) {
|
|
2992
|
+
const from = script2 ? `, opened by ${basename3(script2)}` : "";
|
|
2678
2993
|
return {
|
|
2679
2994
|
title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
|
|
2680
2995
|
purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
|
|
@@ -2682,7 +2997,7 @@ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
|
|
|
2682
2997
|
}
|
|
2683
2998
|
function rrwebSource() {
|
|
2684
2999
|
const entry = createRequire(import.meta.url).resolve("rrweb");
|
|
2685
|
-
return readFileSync(
|
|
3000
|
+
return readFileSync(join7(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
|
|
2686
3001
|
}
|
|
2687
3002
|
function pageScript(rrweb) {
|
|
2688
3003
|
return `(() => {
|
|
@@ -2733,8 +3048,8 @@ var Session = class {
|
|
|
2733
3048
|
bytes = 0;
|
|
2734
3049
|
constructor(dir, recording, url) {
|
|
2735
3050
|
const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
2736
|
-
this.file =
|
|
2737
|
-
this.metaFile =
|
|
3051
|
+
this.file = join7(dir, `${id}${REPLAY_SUFFIX}`);
|
|
3052
|
+
this.metaFile = join7(dir, `${id}${META_SUFFIX}`);
|
|
2738
3053
|
this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, blockedExternalWrites: 0, truncated: false, automatic: recording.automatic };
|
|
2739
3054
|
writeFileSync(this.file, "");
|
|
2740
3055
|
this.save();
|
|
@@ -2899,7 +3214,7 @@ function installAutoRecording() {
|
|
|
2899
3214
|
return exports;
|
|
2900
3215
|
};
|
|
2901
3216
|
}
|
|
2902
|
-
if (
|
|
3217
|
+
if (process4.env[RECORDINGS_DIR_ENV]) {
|
|
2903
3218
|
installAutoRecording();
|
|
2904
3219
|
}
|
|
2905
3220
|
|
|
@@ -2907,10 +3222,10 @@ if (process2.env[RECORDINGS_DIR_ENV]) {
|
|
|
2907
3222
|
var RECORDER_ENV = "TAMSENSE_RECORDER";
|
|
2908
3223
|
function bundledRecorder() {
|
|
2909
3224
|
const path = fileURLToPath(new URL("./recorder.cjs", import.meta.url));
|
|
2910
|
-
return
|
|
3225
|
+
return existsSync5(path) ? path : null;
|
|
2911
3226
|
}
|
|
2912
3227
|
function recordingsFolder(home, runId) {
|
|
2913
|
-
return
|
|
3228
|
+
return join8(home, ".tamsense", "recordings", `run-${runId}`);
|
|
2914
3229
|
}
|
|
2915
3230
|
function nodeOptionsWithRecorder(recorder, nodeOptions) {
|
|
2916
3231
|
return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
|
|
@@ -2983,7 +3298,7 @@ async function sleep(ms, signal) {
|
|
|
2983
3298
|
// src/update.ts
|
|
2984
3299
|
import { spawn as spawn4 } from "child_process";
|
|
2985
3300
|
import { readFileSync as readFileSync2 } from "fs";
|
|
2986
|
-
import
|
|
3301
|
+
import process5 from "process";
|
|
2987
3302
|
var PACKAGE_NAME = "@tamsensedev/runner";
|
|
2988
3303
|
var RELAUNCHED_ENV = "TAMSENSE_RELAUNCHED";
|
|
2989
3304
|
var NO_UPDATE_ENV = "TAMSENSE_NO_UPDATE";
|
|
@@ -3035,19 +3350,19 @@ function relaunchArgs(argv) {
|
|
|
3035
3350
|
return ["--yes", `${PACKAGE_NAME}@latest`, ...argv];
|
|
3036
3351
|
}
|
|
3037
3352
|
function relaunch(argv, env) {
|
|
3038
|
-
const isWindows =
|
|
3039
|
-
return new Promise((
|
|
3353
|
+
const isWindows = process5.platform === "win32";
|
|
3354
|
+
return new Promise((resolve5, reject) => {
|
|
3040
3355
|
const child = spawn4(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
|
|
3041
3356
|
const ignoreInterrupt = () => {
|
|
3042
3357
|
};
|
|
3043
|
-
|
|
3358
|
+
process5.on("SIGINT", ignoreInterrupt);
|
|
3044
3359
|
child.on("error", (error) => {
|
|
3045
|
-
|
|
3360
|
+
process5.off("SIGINT", ignoreInterrupt);
|
|
3046
3361
|
reject(error);
|
|
3047
3362
|
});
|
|
3048
3363
|
child.on("close", (code) => {
|
|
3049
|
-
|
|
3050
|
-
|
|
3364
|
+
process5.off("SIGINT", ignoreInterrupt);
|
|
3365
|
+
resolve5(code ?? 1);
|
|
3051
3366
|
});
|
|
3052
3367
|
});
|
|
3053
3368
|
}
|
|
@@ -3055,7 +3370,7 @@ function relaunch(argv, env) {
|
|
|
3055
3370
|
// src/worker.ts
|
|
3056
3371
|
import { rm as rm6 } from "fs/promises";
|
|
3057
3372
|
import { dirname as dirname5 } from "path";
|
|
3058
|
-
import
|
|
3373
|
+
import process6 from "process";
|
|
3059
3374
|
|
|
3060
3375
|
// src/feedQueue.ts
|
|
3061
3376
|
var MAX_BATCH = 200;
|
|
@@ -3094,9 +3409,9 @@ function feedQueue(send) {
|
|
|
3094
3409
|
}
|
|
3095
3410
|
|
|
3096
3411
|
// src/library.ts
|
|
3097
|
-
import { existsSync as
|
|
3412
|
+
import { existsSync as existsSync6 } from "fs";
|
|
3098
3413
|
import { cp, mkdir as mkdir4, rm as rm5 } from "fs/promises";
|
|
3099
|
-
import { join as
|
|
3414
|
+
import { join as join9 } from "path";
|
|
3100
3415
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3101
3416
|
var LIBRARY_PATH = fileURLToPath2(new URL("../library", import.meta.url));
|
|
3102
3417
|
var SKILL_NAME = /^[a-z0-9-]+$/;
|
|
@@ -3109,18 +3424,18 @@ function allowedSkills(stage) {
|
|
|
3109
3424
|
return skills && [.../* @__PURE__ */ new Set([...skills, ...STAGE_SKILLS[stage.kind] ?? []])].filter((name) => SKILL_NAME.test(name));
|
|
3110
3425
|
}
|
|
3111
3426
|
function roleLibraryFolder(home, runId) {
|
|
3112
|
-
return
|
|
3427
|
+
return join9(home, ".tamsense", "libraries", `run-${runId}`);
|
|
3113
3428
|
}
|
|
3114
3429
|
async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
|
|
3115
3430
|
if (skills === null) {
|
|
3116
3431
|
return source;
|
|
3117
3432
|
}
|
|
3118
3433
|
await rm5(folder, { recursive: true, force: true });
|
|
3119
|
-
await mkdir4(
|
|
3120
|
-
await cp(
|
|
3121
|
-
await cp(
|
|
3122
|
-
for (const skill of skills.filter((name) => SKILL_NAME.test(name) &&
|
|
3123
|
-
await cp(
|
|
3434
|
+
await mkdir4(join9(folder, "skills"), { recursive: true });
|
|
3435
|
+
await cp(join9(source, ".claude-plugin"), join9(folder, ".claude-plugin"), { recursive: true });
|
|
3436
|
+
await cp(join9(source, "agents"), join9(folder, "agents"), { recursive: true });
|
|
3437
|
+
for (const skill of skills.filter((name) => SKILL_NAME.test(name) && existsSync6(join9(source, "skills", name)))) {
|
|
3438
|
+
await cp(join9(source, "skills", skill), join9(folder, "skills", skill), { recursive: true });
|
|
3124
3439
|
}
|
|
3125
3440
|
return folder;
|
|
3126
3441
|
}
|
|
@@ -3129,7 +3444,7 @@ async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
|
|
|
3129
3444
|
import { randomUUID } from "crypto";
|
|
3130
3445
|
import { createReadStream } from "fs";
|
|
3131
3446
|
import { readdir, readFile as readFile4, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
3132
|
-
import { basename as
|
|
3447
|
+
import { basename as basename4, dirname as dirname4, extname as extname2, isAbsolute as isAbsolute3, join as join10 } from "path";
|
|
3133
3448
|
import { createInterface as createInterface2 } from "readline";
|
|
3134
3449
|
import { Readable } from "stream";
|
|
3135
3450
|
import { promisify } from "util";
|
|
@@ -3190,7 +3505,7 @@ function marksFrom(marks, startTs) {
|
|
|
3190
3505
|
return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
|
|
3191
3506
|
}
|
|
3192
3507
|
function sessionIdOf(file) {
|
|
3193
|
-
return
|
|
3508
|
+
return basename4(file).replace(REPLAY_SUFFIX, "");
|
|
3194
3509
|
}
|
|
3195
3510
|
async function readMeta(file) {
|
|
3196
3511
|
try {
|
|
@@ -3201,12 +3516,12 @@ async function readMeta(file) {
|
|
|
3201
3516
|
}
|
|
3202
3517
|
async function metaFor(kind, file) {
|
|
3203
3518
|
if (kind === "replay") {
|
|
3204
|
-
return readMeta(
|
|
3519
|
+
return readMeta(join10(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
|
|
3205
3520
|
}
|
|
3206
|
-
const folder =
|
|
3521
|
+
const folder = basename4(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
|
|
3207
3522
|
for (const name of await readdir(folder).catch(() => [])) {
|
|
3208
3523
|
if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
|
|
3209
|
-
const meta = await readMeta(
|
|
3524
|
+
const meta = await readMeta(join10(folder, name));
|
|
3210
3525
|
if (meta?.video === file) {
|
|
3211
3526
|
return meta;
|
|
3212
3527
|
}
|
|
@@ -3216,7 +3531,7 @@ async function metaFor(kind, file) {
|
|
|
3216
3531
|
}
|
|
3217
3532
|
async function ledger(dir) {
|
|
3218
3533
|
try {
|
|
3219
|
-
return new Set(JSON.parse(await readFile4(
|
|
3534
|
+
return new Set(JSON.parse(await readFile4(join10(dir, LEDGER_FILE), "utf8")));
|
|
3220
3535
|
} catch {
|
|
3221
3536
|
return /* @__PURE__ */ new Set();
|
|
3222
3537
|
}
|
|
@@ -3224,7 +3539,7 @@ async function ledger(dir) {
|
|
|
3224
3539
|
async function remember(dir, file) {
|
|
3225
3540
|
const done2 = await ledger(dir);
|
|
3226
3541
|
done2.add(file);
|
|
3227
|
-
await writeFile4(
|
|
3542
|
+
await writeFile4(join10(dir, LEDGER_FILE), JSON.stringify([...done2]));
|
|
3228
3543
|
}
|
|
3229
3544
|
async function nonEmpty(file) {
|
|
3230
3545
|
return file !== null && ((await stat2(file).catch(() => null))?.size ?? 0) > 0;
|
|
@@ -3237,8 +3552,8 @@ async function pendingRecordings(dir) {
|
|
|
3237
3552
|
if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
|
|
3238
3553
|
continue;
|
|
3239
3554
|
}
|
|
3240
|
-
const meta = await readMeta(
|
|
3241
|
-
const replay =
|
|
3555
|
+
const meta = await readMeta(join10(dir, name));
|
|
3556
|
+
const replay = join10(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
|
|
3242
3557
|
if (!done2.has(replay) && await nonEmpty(replay)) {
|
|
3243
3558
|
pending.push({ kind: "replay", file: replay, meta });
|
|
3244
3559
|
}
|
|
@@ -3258,7 +3573,7 @@ async function uploadReplay(upload, file, caption) {
|
|
|
3258
3573
|
if (!scan.snapshot) {
|
|
3259
3574
|
throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
|
|
3260
3575
|
}
|
|
3261
|
-
return finish(upload, { kind: "replay", upload: id, name:
|
|
3576
|
+
return finish(upload, { kind: "replay", upload: id, name: basename4(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
|
|
3262
3577
|
}
|
|
3263
3578
|
async function uploadVideo(upload, file, caption, meta) {
|
|
3264
3579
|
const contentType = VIDEO_TYPES_BY_EXTENSION[extname2(file).toLowerCase()];
|
|
@@ -3273,13 +3588,13 @@ async function uploadVideo(upload, file, caption, meta) {
|
|
|
3273
3588
|
const endedAt = meta?.endedAt ?? info.mtimeMs;
|
|
3274
3589
|
const durationMs = Math.max(0, Math.round(endedAt - startedAt));
|
|
3275
3590
|
const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
|
|
3276
|
-
return finish(upload, { kind: "video", upload: id, name:
|
|
3591
|
+
return finish(upload, { kind: "video", upload: id, name: basename4(file), ...caption, contentType, durationMs, marks });
|
|
3277
3592
|
}
|
|
3278
3593
|
async function finish(upload, body) {
|
|
3279
3594
|
return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
|
|
3280
3595
|
}
|
|
3281
3596
|
async function attachRecording(api, runId, recordingsDir2, request) {
|
|
3282
|
-
if (!
|
|
3597
|
+
if (!isAbsolute3(request.path)) {
|
|
3283
3598
|
return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
|
|
3284
3599
|
}
|
|
3285
3600
|
const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname2(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
|
|
@@ -3348,7 +3663,7 @@ async function uploadPendingRecordings(api, runId, dir) {
|
|
|
3348
3663
|
|
|
3349
3664
|
// src/screenshot.ts
|
|
3350
3665
|
import { readFile as readFile5, stat as stat3 } from "fs/promises";
|
|
3351
|
-
import { basename as
|
|
3666
|
+
import { basename as basename5, extname as extname3, isAbsolute as isAbsolute4 } from "path";
|
|
3352
3667
|
var TYPES_BY_EXTENSION2 = {
|
|
3353
3668
|
".png": "image/png",
|
|
3354
3669
|
".jpg": "image/jpeg",
|
|
@@ -3356,7 +3671,7 @@ var TYPES_BY_EXTENSION2 = {
|
|
|
3356
3671
|
".webp": "image/webp"
|
|
3357
3672
|
};
|
|
3358
3673
|
async function readScreenshot(request) {
|
|
3359
|
-
if (!
|
|
3674
|
+
if (!isAbsolute4(request.path)) {
|
|
3360
3675
|
return "Give the absolute path of the image file.";
|
|
3361
3676
|
}
|
|
3362
3677
|
const contentType = TYPES_BY_EXTENSION2[extname3(request.path).toLowerCase()];
|
|
@@ -3377,7 +3692,7 @@ async function readScreenshot(request) {
|
|
|
3377
3692
|
return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
|
|
3378
3693
|
}
|
|
3379
3694
|
const data = (await readFile5(request.path)).toString("base64");
|
|
3380
|
-
return { name:
|
|
3695
|
+
return { name: basename5(request.path), ...caption, contentType, data };
|
|
3381
3696
|
}
|
|
3382
3697
|
|
|
3383
3698
|
// src/worker.ts
|
|
@@ -3577,10 +3892,18 @@ async function prepare(job, tools) {
|
|
|
3577
3892
|
const { results, ...workspace2 } = await prepareFolder(job, taskFolder(tools.home, job), tools.api);
|
|
3578
3893
|
return { workspace: workspace2, results, identity: {} };
|
|
3579
3894
|
}
|
|
3895
|
+
if (isLocalJob(job)) {
|
|
3896
|
+
tools.print("Preparing the code from the folders on this computer\u2026");
|
|
3897
|
+
const workspace2 = await prepareLocalWorkspace(job, tools);
|
|
3898
|
+
return { workspace: workspace2, results: null, identity: await commitIdentityEnv(job.repositories[0]?.folder ?? tools.home) };
|
|
3899
|
+
}
|
|
3580
3900
|
tools.print("Preparing the code\u2026");
|
|
3581
3901
|
const workspace = await prepareWorkspace(job, await gitToken(tools.api, job), tools);
|
|
3582
3902
|
return { workspace, results: null, identity: await commitIdentityEnv(tools.home) };
|
|
3583
3903
|
}
|
|
3904
|
+
async function publish(workspace, job, api, identity) {
|
|
3905
|
+
return isLocalJob(job) ? await publishLocalWorkspace(workspace, job, identity) : await publishWorkspace(workspace, job, await gitToken(api, job), identity);
|
|
3906
|
+
}
|
|
3584
3907
|
async function executeJob(job, tools, signal, input, record) {
|
|
3585
3908
|
const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
|
|
3586
3909
|
const { workspace, results, identity } = await prepare(job, tools);
|
|
@@ -3594,7 +3917,7 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3594
3917
|
const attached = { count: 0 };
|
|
3595
3918
|
const session = sessionReporter(tools.api, job, tools.print);
|
|
3596
3919
|
const recordings = recordingsFolder(tools.home, job.runId);
|
|
3597
|
-
const baseEnv = withNodeOnPath(
|
|
3920
|
+
const baseEnv = withNodeOnPath(process6.env, dirname5(process6.execPath));
|
|
3598
3921
|
const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
|
|
3599
3922
|
const outcome = await tools.runAgent({
|
|
3600
3923
|
cwd: workspace.root,
|
|
@@ -3622,7 +3945,7 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3622
3945
|
signal.throwIfAborted();
|
|
3623
3946
|
const waitingFor = outcome.waitingFor ?? null;
|
|
3624
3947
|
const shouldPublish = job.stage.kind === "code" && !isFolderJob(job) && !isReadOnlyCode(job.stage) && outcome.completed && waitingFor === null;
|
|
3625
|
-
const pushed = shouldPublish ? await
|
|
3948
|
+
const pushed = shouldPublish ? await publish(workspace, job, tools.api, identity) : [];
|
|
3626
3949
|
return {
|
|
3627
3950
|
outcome: outcome.completed ? "succeeded" : "failed",
|
|
3628
3951
|
report: outcome.report,
|
|
@@ -3642,7 +3965,7 @@ function clockOf(iso) {
|
|
|
3642
3965
|
const time = new Date(iso);
|
|
3643
3966
|
return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
|
|
3644
3967
|
}
|
|
3645
|
-
function resultLine(finish2,
|
|
3968
|
+
function resultLine(finish2, workspace = "repositories") {
|
|
3646
3969
|
if (finish2.pausedUntil) {
|
|
3647
3970
|
return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
|
|
3648
3971
|
}
|
|
@@ -3664,7 +3987,13 @@ function resultLine(finish2, folder = false) {
|
|
|
3664
3987
|
if (finish2.outcome !== "succeeded") {
|
|
3665
3988
|
return `Failed: ${finish2.report}`;
|
|
3666
3989
|
}
|
|
3667
|
-
|
|
3990
|
+
if (workspace === "folder") {
|
|
3991
|
+
return "Done. The result is on the task on TAMsense.";
|
|
3992
|
+
}
|
|
3993
|
+
if (workspace === "local") {
|
|
3994
|
+
return finish2.pushed.length > 0 ? `Done. ${finish2.pushed.map(localBranchLine).join(" ")}` : "Done. Nothing new to commit.";
|
|
3995
|
+
}
|
|
3996
|
+
return `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.`;
|
|
3668
3997
|
}
|
|
3669
3998
|
async function sendRecordings(job, tools) {
|
|
3670
3999
|
const folder = recordingsFolder(tools.home, job.runId);
|
|
@@ -3727,7 +4056,7 @@ async function runJob(job, tools, stop) {
|
|
|
3727
4056
|
await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf3(error)}`));
|
|
3728
4057
|
try {
|
|
3729
4058
|
await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
|
|
3730
|
-
tools.print(resultLine(finish2,
|
|
4059
|
+
tools.print(resultLine(finish2, job.workspace));
|
|
3731
4060
|
} catch (error) {
|
|
3732
4061
|
tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
|
|
3733
4062
|
}
|
|
@@ -3765,6 +4094,7 @@ async function work(tools, stop, parallel = 1) {
|
|
|
3765
4094
|
const running = /* @__PURE__ */ new Set();
|
|
3766
4095
|
const folderLock = keyedLock();
|
|
3767
4096
|
const reporting = reportCapabilities(tools, stopAll);
|
|
4097
|
+
const checking = tools.inspectFolder ? checkFolders({ ...tools, inspect: tools.inspectFolder }, stopAll) : Promise.resolve();
|
|
3768
4098
|
const start = (job) => {
|
|
3769
4099
|
const runTools = jobTools(job, tools, parallel);
|
|
3770
4100
|
const task = folderLock.run(stepFolder(job).join("/"), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
|
|
@@ -3795,7 +4125,7 @@ async function work(tools, stop, parallel = 1) {
|
|
|
3795
4125
|
}
|
|
3796
4126
|
}
|
|
3797
4127
|
} finally {
|
|
3798
|
-
await Promise.allSettled([...running, reporting]);
|
|
4128
|
+
await Promise.allSettled([...running, reporting, checking]);
|
|
3799
4129
|
}
|
|
3800
4130
|
}
|
|
3801
4131
|
|
|
@@ -3806,11 +4136,11 @@ var NO_TASKS_ACCESS_TEXT = "Your account no longer has access to Tasks. Ask the
|
|
|
3806
4136
|
var ACCESS_PROBLEMS = { 401: DISCONNECTED_TEXT, 403: NO_TASKS_ACCESS_TEXT };
|
|
3807
4137
|
var INTERRUPTED_EXIT_CODE = 130;
|
|
3808
4138
|
function print(line) {
|
|
3809
|
-
|
|
4139
|
+
process7.stdout.write(`${line}
|
|
3810
4140
|
`);
|
|
3811
4141
|
}
|
|
3812
4142
|
function serverFor(flag, saved) {
|
|
3813
|
-
return serverOrigin(flag ??
|
|
4143
|
+
return serverOrigin(flag ?? process7.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
|
|
3814
4144
|
}
|
|
3815
4145
|
function accessProblem(error) {
|
|
3816
4146
|
return error instanceof ApiError ? ACCESS_PROBLEMS[error.status] ?? null : null;
|
|
@@ -3836,15 +4166,15 @@ async function runLogin(server, deviceName) {
|
|
|
3836
4166
|
}
|
|
3837
4167
|
function stopOnInterrupt() {
|
|
3838
4168
|
const stop = new AbortController();
|
|
3839
|
-
|
|
4169
|
+
process7.once("SIGINT", () => {
|
|
3840
4170
|
print("Stopping\u2026 Press Ctrl+C again to quit at once.");
|
|
3841
4171
|
stop.abort();
|
|
3842
|
-
|
|
4172
|
+
process7.once("SIGINT", () => process7.exit(INTERRUPTED_EXIT_CODE));
|
|
3843
4173
|
});
|
|
3844
4174
|
return stop.signal;
|
|
3845
4175
|
}
|
|
3846
4176
|
function installTools(home) {
|
|
3847
|
-
return { home, nodePath:
|
|
4177
|
+
return { home, nodePath: process7.execPath, env: process7.env, print, run: runProcess, exists: existsSync7 };
|
|
3848
4178
|
}
|
|
3849
4179
|
async function browserForStart(home, install) {
|
|
3850
4180
|
const installed = await installedBrowser(home);
|
|
@@ -3854,13 +4184,13 @@ async function browserForStart(home, install) {
|
|
|
3854
4184
|
return ensureBrowser({ print, install: () => installBrowser(installTools(home)) });
|
|
3855
4185
|
}
|
|
3856
4186
|
async function startedNewVersion() {
|
|
3857
|
-
const update = await updateToRun(
|
|
4187
|
+
const update = await updateToRun(process7.env, currentVersion(), () => latestVersion());
|
|
3858
4188
|
if (!update) {
|
|
3859
4189
|
return false;
|
|
3860
4190
|
}
|
|
3861
4191
|
print(`TAMsense runner ${update.latest} is out, this is ${update.current}. Starting the new version...`);
|
|
3862
4192
|
try {
|
|
3863
|
-
|
|
4193
|
+
process7.exitCode = await relaunch(process7.argv.slice(2), process7.env);
|
|
3864
4194
|
return true;
|
|
3865
4195
|
} catch {
|
|
3866
4196
|
print("Could not start the new version. Going on with this one.");
|
|
@@ -3878,14 +4208,14 @@ async function runStart(parallel, installsBrowser) {
|
|
|
3878
4208
|
}
|
|
3879
4209
|
const api = createApi(saved.server, saved.token);
|
|
3880
4210
|
const identity = await api.request("GET", IDENTITY_PATH);
|
|
3881
|
-
const
|
|
3882
|
-
if (
|
|
3883
|
-
throw new Error(
|
|
4211
|
+
const problem2 = teamKeyProblem(identity.device.team, process7.env);
|
|
4212
|
+
if (problem2) {
|
|
4213
|
+
throw new Error(problem2);
|
|
3884
4214
|
}
|
|
3885
4215
|
print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
|
|
3886
4216
|
const home = homedir3();
|
|
3887
4217
|
const browser = await browserForStart(home, installsBrowser);
|
|
3888
|
-
await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
|
|
4218
|
+
await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder(), inspectFolder: (path) => inspectFolder(path, home) }, stopOnInterrupt(), parallel);
|
|
3889
4219
|
}
|
|
3890
4220
|
async function runBrowser(remove) {
|
|
3891
4221
|
const home = homedir3();
|
|
@@ -3937,10 +4267,10 @@ async function run(command) {
|
|
|
3937
4267
|
}
|
|
3938
4268
|
}
|
|
3939
4269
|
try {
|
|
3940
|
-
await run(parseCommand(
|
|
4270
|
+
await run(parseCommand(process7.argv.slice(2), process7.env));
|
|
3941
4271
|
} catch (error) {
|
|
3942
|
-
|
|
4272
|
+
process7.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
|
|
3943
4273
|
`);
|
|
3944
|
-
|
|
4274
|
+
process7.exitCode = 1;
|
|
3945
4275
|
}
|
|
3946
4276
|
//# sourceMappingURL=cli.js.map
|