toolroll 0.8.1 → 0.9.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/CHANGELOG.md +49 -0
- package/dist/agent-onboard.d.ts +7 -0
- package/dist/agent-onboard.js +23 -5
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -20
- package/dist/browser-workspace.d.ts +10 -0
- package/dist/chat-delivery.js +51 -3
- package/dist/chat-quiet.d.ts +28 -0
- package/dist/chat-quiet.js +161 -0
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/digest-times.d.ts +3 -0
- package/dist/digest-times.js +7 -0
- package/dist/dispose.js +2 -0
- package/dist/export.js +1 -1
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +2 -1
- package/dist/integrations-ui.d.ts +13 -0
- package/dist/integrations-ui.js +65 -0
- package/dist/integrations.d.ts +113 -0
- package/dist/integrations.js +526 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +5 -6
- package/dist/operate.js +254 -25
- package/dist/project-delete.js +1 -0
- package/dist/prompt.d.ts +7 -2
- package/dist/prompt.js +36 -7
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +9 -2
- package/dist/pull-request-flow.d.ts +140 -0
- package/dist/pull-request-flow.js +440 -0
- package/dist/pull-request-schema.d.ts +5 -0
- package/dist/pull-request-schema.js +21 -0
- package/dist/pull-request-ui.d.ts +23 -0
- package/dist/pull-request-ui.js +55 -0
- package/dist/pulls.d.ts +5 -0
- package/dist/pulls.js +11 -0
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +323 -26
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +66 -5
- package/dist/store.js +160 -19
- package/dist/surface.js +2 -0
- package/dist/telegram-progress.d.ts +1 -0
- package/dist/telegram-progress.js +1 -1
- package/dist/telegram.js +95 -15
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +1 -1
- package/dist/toolroll-update.d.ts +26 -6
- package/dist/toolroll-update.js +188 -50
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
package/dist/operate.js
CHANGED
|
@@ -3,7 +3,8 @@ import { parseProtectedPaths } from "./approval-policy.js";
|
|
|
3
3
|
import { rulesSummary } from "./approval-rules-ui.js";
|
|
4
4
|
import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
|
|
5
5
|
import { matchesOutsideCheckpoint } from "./ledger-chain.js";
|
|
6
|
-
import { bytesWords, storageReport } from "./storage.js";
|
|
6
|
+
import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
|
|
7
|
+
import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
|
|
7
8
|
import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
|
|
8
9
|
import { checkPolicy, parseList, policyParts } from "./policy.js";
|
|
9
10
|
import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
|
|
@@ -12,6 +13,7 @@ import { buildExport, exportSummary, exportZip, writeExportFolder } from "./expo
|
|
|
12
13
|
import { startBudgetAlerts } from "./budget-alerts.js";
|
|
13
14
|
import { liftAuthPause, openAuthPauses, signInGate, signInWords, startSignInProbes } from "./provider-auth.js";
|
|
14
15
|
import { createConnectionChecker } from "./provider-connection.js";
|
|
16
|
+
import { checkIntegrations, integrationsBrokenLine, integrationsNow, renderIntegrations } from "./integrations.js";
|
|
15
17
|
import { buildHandoff, handoffLines, loginAccount, runOnboard } from "./agent-onboard.js";
|
|
16
18
|
import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
|
|
17
19
|
import { pushLimitSink } from "./provider-limits.js";
|
|
@@ -66,7 +68,7 @@ import { runTaskOutcomeCommand } from "./task-outcome-cli.js";
|
|
|
66
68
|
*/
|
|
67
69
|
import { homedir, hostname, tmpdir } from "node:os";
|
|
68
70
|
import { basename, dirname, join, resolve } from "node:path";
|
|
69
|
-
import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, } from "./store.js";
|
|
71
|
+
import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, } from "./store.js";
|
|
70
72
|
import { randomBytes, randomInt, randomUUID } from "node:crypto";
|
|
71
73
|
import { authorizePlanUnderMode } from "./plan-auto.js";
|
|
72
74
|
import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
|
|
@@ -135,6 +137,7 @@ import { verifyApproverByPassword } from "./principal.js";
|
|
|
135
137
|
import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
|
|
136
138
|
import { tally, spendLine } from "./summary.js";
|
|
137
139
|
import { bodyHashOf, describePublicationGrant, observeChecks, publicationBody, publishPass, sweepMerges, } from "./publish.js";
|
|
140
|
+
import { checkPublishing, publishingOf, savePublishing, followPullRequests } from "./pull-request-flow.js";
|
|
138
141
|
import { acquire, acquireFallback, acquireIfReady, completeFenced, finalizeFailureFenced, finalizeMalformedFenced, finalizeParkFenced, finalizePlanFenced, finalizePlanFailureFenced, finalizeScoutFenced, finalizeScoutFailureFenced, heartbeat, release, reap, currentClaim, DEFAULT_LEASE_MS, SYNC_MAX_AGE_MS, acquireContinuation, } from "./claim.js";
|
|
139
142
|
import { disposeBuildOutcome, holdStaleApproval, regateTask } from "./dispose.js";
|
|
140
143
|
import { attendedLivenessState } from "./liveness.js";
|
|
@@ -193,10 +196,6 @@ import { PACKAGE_VERSION } from "./version.js";
|
|
|
193
196
|
* caller deciding whether to try the next task or wake somebody up.
|
|
194
197
|
*/
|
|
195
198
|
export const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
|
|
196
|
-
/** Storage retention: how long a finished task's clean checkout is kept after it was let go, and a result marked
|
|
197
|
-
* complete's or a release candidate's after it was (its checkout holds the build a deploy installs). */
|
|
198
|
-
export const CHECKOUT_KEEP_MS = 2 * 24 * 60 * 60_000;
|
|
199
|
-
export const RESULT_KEEP_MS = 7 * 24 * 60 * 60_000;
|
|
200
199
|
const STATES = ["queued", "running", "done", "failed", "cancelled"];
|
|
201
200
|
export const OPERATE_HELP = `toolroll — operating the queue
|
|
202
201
|
|
|
@@ -213,11 +212,17 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
213
212
|
--not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
|
|
214
213
|
toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
|
|
215
214
|
toolroll storage where the disk goes: database, build checkouts, releases, evidence
|
|
215
|
+
toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
|
|
216
|
+
toolroll storage cleanup <when> remove a finished task's clean checkout: finished|2d|7d|never (instance operator)
|
|
217
|
+
toolroll storage discard <path> --yes throw away a checkout kept for its changes; its branch stays
|
|
218
|
+
toolroll integrations [--json] which integrations work: Connected, Not set up or Broken, and what to do (--saved: no new checks)
|
|
216
219
|
toolroll monitoring where the audit stream and traces go, and how each destination is doing
|
|
217
220
|
toolroll check-progress <run> current or final approved check progress
|
|
218
221
|
toolroll spend [--month YYYY-MM] [--csv] what agent work cost, by project, person, teammate and model
|
|
219
222
|
toolroll export --out <path> [--zip] everything Toolroll knows, in a folder or .zip (no secrets)
|
|
220
223
|
toolroll budget list|set|remove monthly budgets (--all | --project <p> | --person <name> | --teammate <id>) --usd <n> [--alerts-only]
|
|
224
|
+
toolroll notifications [quiet|all] how chats reach you: only when you're needed (the default), or every step
|
|
225
|
+
toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
|
|
221
226
|
toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
|
|
222
227
|
toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
|
|
223
228
|
toolroll backup now|list back the database up now; list backups and how the last ones went
|
|
@@ -264,6 +269,10 @@ External trackers — build what a tracker nominates, under local approvals
|
|
|
264
269
|
the dispatch grant: its own explicit
|
|
265
270
|
yes, never in any default; writes a
|
|
266
271
|
plane marker label to the repository
|
|
272
|
+
toolroll publish setup [--repo <path>] [--merge-method squash|merge|rebase]
|
|
273
|
+
[--merge-when-green] --yes one-step pull requests: checks gh sign-in
|
|
274
|
+
and push rights, then Complete offers
|
|
275
|
+
"Complete and open a pull request"
|
|
267
276
|
toolroll publish grant --github <owner/name> --allow-merge
|
|
268
277
|
--merge-method squash|merge|rebase [--merge-delete-branch] --yes
|
|
269
278
|
auto-merge this plane's own PRs —
|
|
@@ -532,7 +541,7 @@ export const TASK_ACTIONS = [
|
|
|
532
541
|
"reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
|
|
533
542
|
"review", "accept", "repair", "route", "stop", "resume", "complete", "revise",
|
|
534
543
|
];
|
|
535
|
-
export const PUBLISH_ACTIONS = ["grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
|
|
544
|
+
export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
|
|
536
545
|
export const CONFIG_ACTIONS = ["show", "set", "clear"];
|
|
537
546
|
export const APPROVER_ACTIONS = ["list", "add"];
|
|
538
547
|
export const ROUTINE_ACTIONS = ["list", "add", "show", "approve", "refresh", "pause", "resume", "run-now"];
|
|
@@ -577,6 +586,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
577
586
|
"html", "csv", "alerts-only",
|
|
578
587
|
// v105: the full export.
|
|
579
588
|
"zip",
|
|
589
|
+
// Settings → Integrations: the last checks, without checking again.
|
|
590
|
+
"saved",
|
|
580
591
|
]);
|
|
581
592
|
export function parseOperateArgs(argv) {
|
|
582
593
|
const positional = [];
|
|
@@ -720,6 +731,7 @@ export async function runOperate(command, argv, write, options = {}) {
|
|
|
720
731
|
...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
|
|
721
732
|
...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
|
|
722
733
|
...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
|
|
734
|
+
...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
|
|
723
735
|
...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
|
|
724
736
|
...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
|
|
725
737
|
...(options.heldCoordinator === undefined ? {} : { heldCoordinator: options.heldCoordinator }),
|
|
@@ -817,19 +829,18 @@ async function dispatch(command, positional, flags, context) {
|
|
|
817
829
|
return budgetCommand(positional, flags, context);
|
|
818
830
|
case "retention":
|
|
819
831
|
return retentionCommand(positional, flags, context);
|
|
832
|
+
case "notifications":
|
|
833
|
+
return notificationsCommand(positional, flags, context);
|
|
820
834
|
case "backup":
|
|
821
835
|
return backupCommand(positional, flags, context);
|
|
822
836
|
case "export":
|
|
823
837
|
return exportCommand(positional, flags, context);
|
|
824
838
|
case "policy":
|
|
825
839
|
return policyCommand(positional, flags, context);
|
|
826
|
-
case "storage":
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
|
|
831
|
-
]);
|
|
832
|
-
}
|
|
840
|
+
case "storage":
|
|
841
|
+
return storageCommand(positional, flags, context);
|
|
842
|
+
case "integrations":
|
|
843
|
+
return integrationsCommand(positional, flags, context);
|
|
833
844
|
case "monitoring": {
|
|
834
845
|
// Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
|
|
835
846
|
const settings = readMonitoring(dirname(context.databaseFile));
|
|
@@ -4313,13 +4324,9 @@ async function reconcileCommand(flags, context) {
|
|
|
4313
4324
|
reaped: reaped.map(claim => claim.leaseId),
|
|
4314
4325
|
});
|
|
4315
4326
|
}
|
|
4316
|
-
// Storage
|
|
4317
|
-
//
|
|
4318
|
-
const pruned = await
|
|
4319
|
-
for (const row of pruned.removed) {
|
|
4320
|
-
store.recordAction({ at: clock().toISOString(), actor: "worker", repo, taskId: row.taskRef === null ? null : store.externalIdFor(row.taskRef), runId: null,
|
|
4321
|
-
action: "checkout removed", outcome: "removed", source: "work", detail: `${basename(row.path)} (released ${row.releasedAt?.slice(0, 10) ?? "?"})` });
|
|
4322
|
-
}
|
|
4327
|
+
// Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
|
|
4328
|
+
// the task is complete or cancelled); their branches stay. Each removal is in the ledger.
|
|
4329
|
+
const pruned = await cleanCheckouts(store, worktrees, clock, { manual: false, actor: "worker", repo });
|
|
4323
4330
|
// Retention (Settings → Retention): once a day, what's older than its setting goes; one ledger entry says what.
|
|
4324
4331
|
let retention = null;
|
|
4325
4332
|
try {
|
|
@@ -4341,7 +4348,7 @@ async function reconcileCommand(flags, context) {
|
|
|
4341
4348
|
reaped: reaped.map(claim => claim.leaseId),
|
|
4342
4349
|
adopted: adoption.adopted,
|
|
4343
4350
|
forgotten: adoption.forgotten,
|
|
4344
|
-
checkoutsRemoved: pruned.removed.map(
|
|
4351
|
+
checkoutsRemoved: pruned.removed.map(one => one.path),
|
|
4345
4352
|
checkoutsKept: pruned.kept,
|
|
4346
4353
|
liveViewsSwept: liveSwept.removed.length,
|
|
4347
4354
|
retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
|
|
@@ -4352,7 +4359,7 @@ async function reconcileCommand(flags, context) {
|
|
|
4352
4359
|
...(reaped.length === 0 ? [] : [`Reaped ${reaped.length} expired lease(s).`]),
|
|
4353
4360
|
...adoption.adopted.map(path => `Adopted ${path} — released, unverified, somebody should look.`),
|
|
4354
4361
|
...adoption.forgotten.map(path => `Forgot ${path} — its directory is gone.`),
|
|
4355
|
-
...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} checkout(s)
|
|
4362
|
+
...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} finished task checkout(s), about ${bytesWords(pruned.freed)}; their branches stay.`]),
|
|
4356
4363
|
...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
|
|
4357
4364
|
...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
|
|
4358
4365
|
]);
|
|
@@ -7239,6 +7246,14 @@ async function runWatchLoop(args) {
|
|
|
7239
7246
|
});
|
|
7240
7247
|
if (checks.failing > 0)
|
|
7241
7248
|
progress(`watch: CI is red on ${checks.failing} published PR(s) — the outbox has it`);
|
|
7249
|
+
const followed = await followPullRequests(store, checks.seen, {
|
|
7250
|
+
evidenceRoot: context.evidenceRoot,
|
|
7251
|
+
...(context.publishExec === undefined ? {} : { exec: context.publishExec }),
|
|
7252
|
+
});
|
|
7253
|
+
if (followed.revisions > 0)
|
|
7254
|
+
progress(`watch: filed ${followed.revisions} CI revision(s)`);
|
|
7255
|
+
if (followed.merged > 0)
|
|
7256
|
+
progress(`watch: merged ${followed.merged} pull request(s) whose checks passed`);
|
|
7242
7257
|
}
|
|
7243
7258
|
catch (error) {
|
|
7244
7259
|
progress(`watch: CI observation failed — ${describe(error)}; retrying on its next interval`);
|
|
@@ -7719,7 +7734,7 @@ async function onboardCommand(flags, context) {
|
|
|
7719
7734
|
const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
|
|
7720
7735
|
const registryPath = registryPathOf(context);
|
|
7721
7736
|
const checker = createConnectionChecker({ home, env });
|
|
7722
|
-
|
|
7737
|
+
const code = await runOnboard({
|
|
7723
7738
|
write,
|
|
7724
7739
|
json,
|
|
7725
7740
|
yes: flag(flags, "yes"),
|
|
@@ -7759,7 +7774,23 @@ async function onboardCommand(flags, context) {
|
|
|
7759
7774
|
return { ok: true, added: "error" in before || !before.repos.includes(repo) };
|
|
7760
7775
|
},
|
|
7761
7776
|
checkConnection: seams.checkConnection ?? (agent => checker(agent)),
|
|
7777
|
+
pullRequests: seams.pullRequests ?? (async (repo) => {
|
|
7778
|
+
const publishing = publishingOf(store, repo);
|
|
7779
|
+
if (publishing.on)
|
|
7780
|
+
return `on for ${publishing.githubRepo} into ${publishing.base}`;
|
|
7781
|
+
const checked = await checkPublishing(repo, context.publishExec === undefined ? {} : { exec: context.publishExec });
|
|
7782
|
+
if (!checked.ok)
|
|
7783
|
+
return `off: ${checked.message}`;
|
|
7784
|
+
return `off: turn on with toolroll publish setup --repo ${repo} --yes (asks your password), or in Settings → Projects`;
|
|
7785
|
+
}),
|
|
7762
7786
|
});
|
|
7787
|
+
// One line when an integration is Broken, from the last checks only (never a new check here).
|
|
7788
|
+
if (!json) {
|
|
7789
|
+
const line = integrationsBrokenLine(await savedIntegrations(context).catch(() => []));
|
|
7790
|
+
if (line !== null)
|
|
7791
|
+
write(line);
|
|
7792
|
+
}
|
|
7793
|
+
return code;
|
|
7763
7794
|
}
|
|
7764
7795
|
async function upCommand(flags, context) {
|
|
7765
7796
|
const { store, write, json, clock } = context;
|
|
@@ -8548,6 +8579,42 @@ async function publishCommand(positional, flags, context) {
|
|
|
8548
8579
|
if (action !== undefined && !PUBLISH_ACTIONS.includes(action)) {
|
|
8549
8580
|
return fail(write, json, "publish", "usage", `unknown \`publish ${action}\` — try ${PUBLISH_ACTIONS.join(", ")}, or bare \`publish\` for the publication pass`, EXIT.usage);
|
|
8550
8581
|
}
|
|
8582
|
+
if (action === "setup") {
|
|
8583
|
+
const mergeMethod = text(flags, "merge-method") ?? "squash";
|
|
8584
|
+
if (!["squash", "merge", "rebase"].includes(mergeMethod)) {
|
|
8585
|
+
return fail(write, json, "publish setup", "usage", "--merge-method is squash, merge or rebase", EXIT.usage);
|
|
8586
|
+
}
|
|
8587
|
+
const checked = await checkPublishing(repo, context.publishExec === undefined ? {} : { exec: context.publishExec });
|
|
8588
|
+
if (!checked.ok)
|
|
8589
|
+
return fail(write, json, "publish setup", checked.reason, checked.message, EXIT.refused);
|
|
8590
|
+
const settings = { mergeMethod: mergeMethod, mergeWhenGreen: flags.has("merge-when-green") };
|
|
8591
|
+
const plan = checked.plan;
|
|
8592
|
+
const terms = [
|
|
8593
|
+
`Pull requests for ${plan.githubRepo} into ${plan.base}${plan.account === null ? "" : `, as ${plan.account}`}.`,
|
|
8594
|
+
"Complete will offer \"Complete and open a pull request\"; the pull request opens from the exact completed commit.",
|
|
8595
|
+
`Merges ${settings.mergeMethod} and delete the branch${settings.mergeWhenGreen ? ", by themselves once checks pass" : " when a person selects Merge"}.`,
|
|
8596
|
+
];
|
|
8597
|
+
if (!flags.has("yes")) {
|
|
8598
|
+
if (json) {
|
|
8599
|
+
write(envelopeJson({ ok: false, command: "publish setup", reason: "unconfirmed", proposed: { ...plan, ...settings } }));
|
|
8600
|
+
return EXIT.refused;
|
|
8601
|
+
}
|
|
8602
|
+
for (const line of terms)
|
|
8603
|
+
write(line);
|
|
8604
|
+
write("Nothing is set up yet. Repeat with --yes --as <you> --token <password> to turn it on.");
|
|
8605
|
+
return EXIT.refused;
|
|
8606
|
+
}
|
|
8607
|
+
const acting = await askCredentials(flags, context);
|
|
8608
|
+
if (acting === null) {
|
|
8609
|
+
return fail(write, json, "publish setup", "usage", "turning on pull requests takes --as <you> --token <your password>", EXIT.usage);
|
|
8610
|
+
}
|
|
8611
|
+
const authenticated = authenticateApprover(store, acting.name, acting.token, repo);
|
|
8612
|
+
if (!authenticated.ok) {
|
|
8613
|
+
return fail(write, json, "publish setup", authenticated.reason, describeApproveFailure(authenticated.reason, acting.name), EXIT.refused);
|
|
8614
|
+
}
|
|
8615
|
+
savePublishing(store, plan, acting.name, settings, clock());
|
|
8616
|
+
return succeed(write, json, "publish setup", { publishing: publishingOf(store, repo) }, () => ["Pull requests are on.", ...terms]);
|
|
8617
|
+
}
|
|
8551
8618
|
if (action === "grant") {
|
|
8552
8619
|
const github = text(flags, "github");
|
|
8553
8620
|
if (github === undefined || !/^[\w.-]+\/[\w.-]+$/.test(github)) {
|
|
@@ -9110,7 +9177,49 @@ async function statusCommand(positional, flags, context) {
|
|
|
9110
9177
|
notifySecurityRelease(context.store, release, current, method.updateCommand, context.clock());
|
|
9111
9178
|
const line = updateLine(release, current, method.updateCommand);
|
|
9112
9179
|
const update = release === null ? {} : { update: { current, latest: release.version, security: release.security, updateCommand: method.updateCommand, url: release.url } };
|
|
9113
|
-
|
|
9180
|
+
// One line when an integration is Broken, from the last checks only: status never waits on a check.
|
|
9181
|
+
const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
|
|
9182
|
+
const brokenLine = integrationsBrokenLine(brokenIntegrations);
|
|
9183
|
+
const integrations = brokenIntegrations.length === 0 ? {} : { integrations: { broken: brokenIntegrations.map(one => ({ key: one.key, name: one.name, fix: one.action.kind === "fix" ? one.action.words : null })) } };
|
|
9184
|
+
return succeed(context.write, context.json, command, { ...status, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
|
|
9185
|
+
}
|
|
9186
|
+
/** Where `integrations` looks: the installation's files beside the database, and every project it knows. */
|
|
9187
|
+
async function integrationIoFor(context) {
|
|
9188
|
+
const registered = await loadProjectRegistry(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
|
|
9189
|
+
const repos = [...new Set([...("error" in registered ? [] : registered.repos), ...context.store.listProjects().map(one => one.path)])];
|
|
9190
|
+
const seams = context.integrationIo ?? {};
|
|
9191
|
+
return {
|
|
9192
|
+
store: context.store,
|
|
9193
|
+
dir: dirname(context.databaseFile),
|
|
9194
|
+
telegramTokenFile: context.telegramTokenFile,
|
|
9195
|
+
env: process.env,
|
|
9196
|
+
repos,
|
|
9197
|
+
gh: (file, args, options) => run(file, [...args], options),
|
|
9198
|
+
checkConnection: createConnectionChecker(),
|
|
9199
|
+
clock: context.clock,
|
|
9200
|
+
...seams,
|
|
9201
|
+
};
|
|
9202
|
+
}
|
|
9203
|
+
/** The integrations from files and the last checks: no network, for `status` and `onboard`. */
|
|
9204
|
+
async function savedIntegrations(context) {
|
|
9205
|
+
return integrationsNow(await integrationIoFor(context));
|
|
9206
|
+
}
|
|
9207
|
+
/** `toolroll integrations [--json]`: check every integration now and say which work. `--saved` skips the checks. */
|
|
9208
|
+
async function integrationsCommand(positional, flags, context) {
|
|
9209
|
+
const command = "integrations";
|
|
9210
|
+
const allowed = new Set(["db", "json", "saved"]);
|
|
9211
|
+
for (const name of flags.keys()) {
|
|
9212
|
+
if (!allowed.has(name))
|
|
9213
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not an integrations option.`, EXIT.usage);
|
|
9214
|
+
}
|
|
9215
|
+
if (positional.length > 0)
|
|
9216
|
+
return fail(context.write, context.json, command, "usage", "Use `toolroll integrations [--saved] [--json]`.", EXIT.usage);
|
|
9217
|
+
const io = await integrationIoFor(context);
|
|
9218
|
+
const list = flag(flags, "saved") ? integrationsNow(io) : await checkIntegrations(io);
|
|
9219
|
+
const counts = { connected: 0, "not-set-up": 0, broken: 0 };
|
|
9220
|
+
for (const one of list)
|
|
9221
|
+
counts[one.state] += 1;
|
|
9222
|
+
return succeed(context.write, context.json, command, { integrations: list, counts }, () => renderIntegrations(list));
|
|
9114
9223
|
}
|
|
9115
9224
|
async function waitTask(positional, flags, context) {
|
|
9116
9225
|
const command = "task wait";
|
|
@@ -11279,6 +11388,35 @@ async function budgetCommand(positional, flags, context) {
|
|
|
11279
11388
|
const saved = store.setBudget({ scope: target.scope, key: target.key, limitMicrousd: Math.round(dollars) * 1_000_000, hardStop: !flags.has("alerts-only") }, acting.name, context.clock());
|
|
11280
11389
|
return succeed(context.write, context.json, command, { budget: saved }, () => [`${budgetLabel(saved).replace(/'s$/, "")}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`]);
|
|
11281
11390
|
}
|
|
11391
|
+
/** `notifications [quiet | all | digest <HH:MM> | digest off]`: how chats reach this person — only when they are
|
|
11392
|
+
* needed (the default), every step, and an optional evening digest. Each person sets their own. */
|
|
11393
|
+
async function notificationsCommand(positional, flags, context) {
|
|
11394
|
+
const [action, value, ...extra] = positional;
|
|
11395
|
+
const command = `notifications${action === undefined ? "" : ` ${action}`}`;
|
|
11396
|
+
const store = context.store;
|
|
11397
|
+
for (const name of flags.keys())
|
|
11398
|
+
if (!["as", "token", "token-file", "token-env", "db", "json"].includes(name))
|
|
11399
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a notifications option.`, EXIT.usage);
|
|
11400
|
+
const usage = "Use notifications quiet, notifications all, or notifications digest <HH:MM>|off.";
|
|
11401
|
+
if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest"].includes(action)) || (action === "digest") !== (value !== undefined)) {
|
|
11402
|
+
return fail(context.write, context.json, command, "usage", usage, EXIT.usage);
|
|
11403
|
+
}
|
|
11404
|
+
if (action === "digest" && value !== "off" && !isDigestTime(value))
|
|
11405
|
+
return fail(context.write, context.json, command, "usage", "The digest time is HH:MM on a 24-hour clock, like 18:30, or off.", EXIT.usage);
|
|
11406
|
+
const acting = await askCredentials(flags, context);
|
|
11407
|
+
const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
|
|
11408
|
+
if (acting === null || verified === null || !verified.ok) {
|
|
11409
|
+
return fail(context.write, context.json, command, "refused", "Notifications are set per person: pass --as and --token (or use the remembered login).", EXIT.refused);
|
|
11410
|
+
}
|
|
11411
|
+
const now = context.clock();
|
|
11412
|
+
const preference = action === "quiet" || action === "all" ? store.setNotificationPreference(acting.name, { mode: action }, acting.name, now)
|
|
11413
|
+
: action === "digest" ? store.setNotificationPreference(acting.name, { digestAt: value === "off" ? null : value }, acting.name, now)
|
|
11414
|
+
: store.notificationPreference(acting.name);
|
|
11415
|
+
return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt }, () => [
|
|
11416
|
+
preference.mode === "quiet" ? "Only when you're needed: one message per task, updated as it moves, and a new one when something needs you." : "Every step: a message for each update.",
|
|
11417
|
+
preference.digestAt === null ? "No evening digest." : `Evening digest at ${preference.digestAt}: what finished, what waits and what failed.`,
|
|
11418
|
+
]);
|
|
11419
|
+
}
|
|
11282
11420
|
/** `retention show | preview | set <kind> <period>` (v105): how long each kind of data is kept, and what the daily
|
|
11283
11421
|
* sweep would remove now. Setting takes an instance operator. */
|
|
11284
11422
|
async function retentionCommand(positional, flags, context) {
|
|
@@ -11323,6 +11461,97 @@ async function retentionCommand(positional, flags, context) {
|
|
|
11323
11461
|
const periods = store.setRetentionPeriod(kind, days, acting.name, context.clock());
|
|
11324
11462
|
return succeed(context.write, context.json, command, { periods }, () => [`${label(kind)}: kept ${days === null ? "forever" : `for ${periodWords(days)}`}.`]);
|
|
11325
11463
|
}
|
|
11464
|
+
/** `storage`, `storage cleanup [<when>]`, `storage clean [--yes]`, `storage discard <path> --yes`: where the disk goes,
|
|
11465
|
+
* and task checkouts: when a finished task's clean checkout is removed, cleaning up now, and throwing away one kept for
|
|
11466
|
+
* its changes. Changing anything takes an instance operator. */
|
|
11467
|
+
async function storageCommand(positional, flags, context) {
|
|
11468
|
+
const [action, ...rest] = positional;
|
|
11469
|
+
const command = `storage ${action ?? ""}`.trim();
|
|
11470
|
+
const store = context.store;
|
|
11471
|
+
const changes = (action === "cleanup" && rest.length > 0) || (action === "clean" && flags.has("yes")) || action === "discard";
|
|
11472
|
+
const allowed = new Set([...(changes ? ["as", "token", "token-file", "token-env"] : []), ...(action === "clean" || action === "discard" ? ["yes"] : []), "db", "json"]);
|
|
11473
|
+
for (const name of flags.keys())
|
|
11474
|
+
if (!allowed.has(name))
|
|
11475
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
|
|
11476
|
+
const pool = new WorktreePool(store, { root: join(dirname(context.databaseFile), "worktrees"), ...(context.gitRunner === undefined ? {} : { runner: context.gitRunner }) });
|
|
11477
|
+
const operator = async () => {
|
|
11478
|
+
const acting = await askCredentials(flags, context);
|
|
11479
|
+
const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
|
|
11480
|
+
return acting !== null && verified !== null && verified.ok && store.isInstanceOperator(acting.name) ? acting.name : null;
|
|
11481
|
+
};
|
|
11482
|
+
const refused = (what) => fail(context.write, context.json, command, "refused", `An instance operator ${what}: pass --as and --token (or use the remembered login).`, EXIT.refused);
|
|
11483
|
+
const summary = (plan) => [
|
|
11484
|
+
`Checkouts: ${plan.count}, ${bytesWords(plan.totalBytes)}. ${plan.waitingReview} waiting for review, ${plan.withChanges} kept because they have changes.`,
|
|
11485
|
+
plan.go.length === 0 ? "A clean up now would free nothing." : `A clean up now would free about ${bytesWords(plan.freeBytes)} (${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}): toolroll storage clean`,
|
|
11486
|
+
`A finished task's clean checkout is removed ${cleanupWords(plan.cleanup)}; its branch stays.`,
|
|
11487
|
+
];
|
|
11488
|
+
const itemLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${one.path}${one.why === null ? "" : ` (${whyWords(one)})`}`;
|
|
11489
|
+
if (action === undefined) {
|
|
11490
|
+
const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
|
|
11491
|
+
const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
|
|
11492
|
+
return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan) }, () => [
|
|
11493
|
+
`${report.folder}: ${bytesWords(report.total)}`,
|
|
11494
|
+
...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
|
|
11495
|
+
...summary(plan),
|
|
11496
|
+
]);
|
|
11497
|
+
}
|
|
11498
|
+
if (action === "cleanup") {
|
|
11499
|
+
if (rest.length === 0) {
|
|
11500
|
+
const cleanup = store.checkoutCleanup();
|
|
11501
|
+
return succeed(context.write, context.json, command, { cleanup }, () => [`A finished task's clean checkout is removed ${cleanupWords(cleanup)}; its branch stays.`]);
|
|
11502
|
+
}
|
|
11503
|
+
const cleanup = rest.length === 1 ? parseCleanup(rest[0]) : undefined;
|
|
11504
|
+
if (cleanup === undefined)
|
|
11505
|
+
return fail(context.write, context.json, command, "usage", `Use storage cleanup <${CLEANUP_CHOICES.map(one => one.value).join("|")}>.`, EXIT.usage);
|
|
11506
|
+
const actor = await operator();
|
|
11507
|
+
if (actor === null)
|
|
11508
|
+
return refused("sets checkout cleanup");
|
|
11509
|
+
const saved = store.setCheckoutCleanup(cleanup, actor, context.clock());
|
|
11510
|
+
return succeed(context.write, context.json, command, { cleanup: saved }, () => [`Saved. A finished task's clean checkout is removed ${cleanupWords(saved)}; its branch stays.`]);
|
|
11511
|
+
}
|
|
11512
|
+
if (action === "clean") {
|
|
11513
|
+
if (rest.length > 0)
|
|
11514
|
+
return fail(context.write, context.json, command, "usage", "Use storage clean to preview, then storage clean --yes.", EXIT.usage);
|
|
11515
|
+
if (!flags.has("yes")) {
|
|
11516
|
+
const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
|
|
11517
|
+
return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes }, () => [
|
|
11518
|
+
...(plan.go.length === 0 ? ["Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
|
|
11519
|
+
...(plan.stay.length === 0 ? [] : [`Stays (${plan.stay.length}):`, ...plan.stay.map(itemLine)]),
|
|
11520
|
+
plan.go.length === 0 ? "Nothing was removed." : "Nothing was removed. Run toolroll storage clean --yes to remove them.",
|
|
11521
|
+
]);
|
|
11522
|
+
}
|
|
11523
|
+
const actor = await operator();
|
|
11524
|
+
if (actor === null)
|
|
11525
|
+
return refused("cleans up checkouts");
|
|
11526
|
+
const done = await cleanCheckouts(store, pool, context.clock, { manual: true, actor });
|
|
11527
|
+
return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed }, () => [
|
|
11528
|
+
done.removed.length === 0 ? "Nothing to clean up." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
|
|
11529
|
+
...done.removed.map(itemLine),
|
|
11530
|
+
...done.kept.map(one => ` kept ${one.path} (${whyWords({ why: one.why })})`),
|
|
11531
|
+
]);
|
|
11532
|
+
}
|
|
11533
|
+
if (action === "discard") {
|
|
11534
|
+
const [path, ...extra] = rest;
|
|
11535
|
+
if (path === undefined || extra.length > 0)
|
|
11536
|
+
return fail(context.write, context.json, command, "usage", "Use storage discard <path> --yes.", EXIT.usage);
|
|
11537
|
+
const target = resolve(path);
|
|
11538
|
+
if (store.getWorktree(target) === null)
|
|
11539
|
+
return fail(context.write, context.json, command, "not-found", `${target} is not a task checkout. toolroll storage clean lists them.`, EXIT.refused);
|
|
11540
|
+
if (!flags.has("yes"))
|
|
11541
|
+
return fail(context.write, context.json, command, "usage", `This throws away ${target} and its uncommitted changes (its branch stays). Add --yes to discard it.`, EXIT.usage);
|
|
11542
|
+
const actor = await operator();
|
|
11543
|
+
if (actor === null)
|
|
11544
|
+
return refused("discards a checkout");
|
|
11545
|
+
const done = await discardCheckout(store, pool, target, context.clock(), actor);
|
|
11546
|
+
if (!done.ok)
|
|
11547
|
+
return fail(context.write, context.json, command, "refused", done.message, EXIT.refused);
|
|
11548
|
+
return succeed(context.write, context.json, command, { discarded: target, freedBytes: done.bytes }, () => [`Discarded ${target}, about ${bytesWords(done.bytes)}; its branch stays.`]);
|
|
11549
|
+
}
|
|
11550
|
+
return fail(context.write, context.json, command, "usage", "Use storage, storage cleanup <when>, storage clean [--yes] or storage discard <path> --yes.", EXIT.usage);
|
|
11551
|
+
}
|
|
11552
|
+
function checkoutSummary(plan) {
|
|
11553
|
+
return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
|
|
11554
|
+
}
|
|
11326
11555
|
/** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
|
|
11327
11556
|
* that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
|
|
11328
11557
|
async function policyCommand(positional, flags, context) {
|
package/dist/project-delete.js
CHANGED
|
@@ -231,6 +231,7 @@ function deleteRows(store, repo, d, now) {
|
|
|
231
231
|
del("telegram_conversation_part", `${IN("conversation")} OR ${IN("source_run")} OR ${IN("task_id")}`, conversations, R, K);
|
|
232
232
|
del("telegram_conversation", IN("id"), conversations);
|
|
233
233
|
del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
|
|
234
|
+
del("chat_card_task", IN("task_ref"), T);
|
|
234
235
|
del("push_delivery", IN("notification"), notifications);
|
|
235
236
|
del("notification_delivery", IN("notification"), notifications);
|
|
236
237
|
del("notification", IN("id"), notifications);
|
package/dist/prompt.d.ts
CHANGED
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/** Whether asking is even possible: a human on both ends of the pipe. */
|
|
9
9
|
export declare function interactive(): boolean;
|
|
10
|
+
/** The program names of this process's ancestors, nearest first: each one's command and, for a script run by node
|
|
11
|
+
* or python, the script's name. Empty where `ps` cannot say. */
|
|
12
|
+
export declare function ancestorPrograms(): string[];
|
|
10
13
|
/**
|
|
11
14
|
* Whether a coding agent is running this command. An agent's shell can be a
|
|
12
15
|
* real terminal, yet whatever is printed lands in its transcript and nobody
|
|
13
|
-
* types at the prompt, so it is treated like no terminal at all.
|
|
16
|
+
* types at the prompt, so it is treated like no terminal at all. The agent is
|
|
17
|
+
* known by a variable it sets or, for one that sets none, by the program above
|
|
18
|
+
* this one. `programs` is read only for this process's own environment.
|
|
14
19
|
*/
|
|
15
|
-
export declare function underAgent(env?: Record<string, string | undefined
|
|
20
|
+
export declare function underAgent(env?: Record<string, string | undefined>, programs?: () => readonly string[]): boolean;
|
|
16
21
|
export declare function ask(question: string): Promise<string>;
|
|
17
22
|
/** Like ask, but what is typed never echoes — passwords live here. */
|
|
18
23
|
/**
|
package/dist/prompt.js
CHANGED
|
@@ -5,22 +5,51 @@
|
|
|
5
5
|
* gets the same immediate usage refusal as always, because a scheduler
|
|
6
6
|
* hanging on "password:" at 3am is the one bug this module must not have.
|
|
7
7
|
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { basename } from "node:path";
|
|
8
10
|
import { createInterface } from "node:readline";
|
|
9
11
|
/** Whether asking is even possible: a human on both ends of the pipe. */
|
|
10
12
|
export function interactive() {
|
|
11
13
|
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
12
14
|
}
|
|
13
|
-
/** Variables a coding agent sets for the commands it runs.
|
|
14
|
-
|
|
15
|
-
const
|
|
15
|
+
/** Variables a coding agent sets for the commands it runs. Only Codex's own CODEX_ names: a person exports others
|
|
16
|
+
* (CODEX_HOME, CODEX_API_KEY, a profile) for themselves. */
|
|
17
|
+
const AGENT_VARIABLES = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "GEMINI_CLI", "CURSOR_AGENT", "OPENCODE", "GOOSE_TERMINAL", "AIDER_CHAT",
|
|
18
|
+
"CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_THREAD_ID", "CODEX_CI", "CODEX_MANAGED_BY_NPM", "CODEX_MANAGED_BY_BUN"];
|
|
19
|
+
/** Agents known by the program running this command, for those that set no variable of their own. */
|
|
20
|
+
const AGENT_PROGRAMS = new Set(["claude", "codex", "gemini", "cursor-agent", "opencode", "aider", "copilot", "amp", "goose", "crush", "qwen"]);
|
|
21
|
+
/** The program names of this process's ancestors, nearest first: each one's command and, for a script run by node
|
|
22
|
+
* or python, the script's name. Empty where `ps` cannot say. */
|
|
23
|
+
export function ancestorPrograms() {
|
|
24
|
+
if (process.platform === "win32")
|
|
25
|
+
return [];
|
|
26
|
+
const names = [];
|
|
27
|
+
let pid = process.ppid;
|
|
28
|
+
for (let depth = 0; depth < 12 && pid > 1; depth++) {
|
|
29
|
+
const answered = spawnSync("/bin/ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
|
|
30
|
+
const found = answered.status === 0 ? /^\s*(\d+)\s+(.*)$/.exec(answered.stdout.trim()) : null;
|
|
31
|
+
if (!found)
|
|
32
|
+
break;
|
|
33
|
+
const words = found[2].split(/\s+/);
|
|
34
|
+
const program = basename(words[0] ?? "");
|
|
35
|
+
names.push(program);
|
|
36
|
+
if (/^(node|bun|deno|python[\d.]*)$/.test(program) && words[1] !== undefined && !words[1].startsWith("-"))
|
|
37
|
+
names.push(basename(words[1]));
|
|
38
|
+
pid = Number(found[1]);
|
|
39
|
+
}
|
|
40
|
+
return names;
|
|
41
|
+
}
|
|
16
42
|
/**
|
|
17
43
|
* Whether a coding agent is running this command. An agent's shell can be a
|
|
18
44
|
* real terminal, yet whatever is printed lands in its transcript and nobody
|
|
19
|
-
* types at the prompt, so it is treated like no terminal at all.
|
|
45
|
+
* types at the prompt, so it is treated like no terminal at all. The agent is
|
|
46
|
+
* known by a variable it sets or, for one that sets none, by the program above
|
|
47
|
+
* this one. `programs` is read only for this process's own environment.
|
|
20
48
|
*/
|
|
21
|
-
export function underAgent(env = process.env) {
|
|
22
|
-
|
|
23
|
-
|
|
49
|
+
export function underAgent(env = process.env, programs = env === process.env ? ancestorPrograms : () => []) {
|
|
50
|
+
if (AGENT_VARIABLES.some(name => env[name] !== undefined && env[name] !== ""))
|
|
51
|
+
return true;
|
|
52
|
+
return programs().some(name => AGENT_PROGRAMS.has(name.replace(/\.(?:js|mjs|cjs|py|exe)$/, "")));
|
|
24
53
|
}
|
|
25
54
|
export function ask(question) {
|
|
26
55
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
package/dist/publish.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @-mention in its conclusion cannot acquire GitHub semantics.
|
|
21
21
|
*/
|
|
22
22
|
import { type ExecResult, type RunOptions } from "./exec.js";
|
|
23
|
+
import { summarizeChecks } from "./pulls.js";
|
|
23
24
|
import type { Publication, PublicationGrant, Store } from "./store.js";
|
|
24
25
|
export declare const BODY_TEMPLATE_VERSION = 2;
|
|
25
26
|
/** After this many failed attempts a publication stops retrying and pages instead. */
|
|
@@ -69,6 +70,16 @@ export type CheckReport = {
|
|
|
69
70
|
failing: number;
|
|
70
71
|
resolved: number;
|
|
71
72
|
problems: string[];
|
|
73
|
+
/** Every observation that won its settle, for the pull-request follower (CI revisions, Ready to merge). */
|
|
74
|
+
seen: ObservedPullRequest[];
|
|
75
|
+
};
|
|
76
|
+
export type ObservedPullRequest = {
|
|
77
|
+
publication: Publication;
|
|
78
|
+
headOid: string;
|
|
79
|
+
state: ReturnType<typeof summarizeChecks>;
|
|
80
|
+
remoteState: "MERGED" | "CLOSED" | null;
|
|
81
|
+
rollup: unknown;
|
|
82
|
+
mergeCommit: string | null;
|
|
72
83
|
};
|
|
73
84
|
/**
|
|
74
85
|
* Read CI for every PR this control plane opened — structured rollup and the
|
package/dist/publish.js
CHANGED
|
@@ -254,7 +254,7 @@ async function openOrAdopt(store, publication, grant, exec, clock) {
|
|
|
254
254
|
export async function observeChecks(store, options) {
|
|
255
255
|
const clock = options.clock ?? (() => new Date());
|
|
256
256
|
const exec = options.exec ?? execRun;
|
|
257
|
-
const report = { observed: 0, failing: 0, resolved: 0, problems: [] };
|
|
257
|
+
const report = { observed: 0, failing: 0, resolved: 0, problems: [], seen: [] };
|
|
258
258
|
for (const publication of store.openedPublications()) {
|
|
259
259
|
if (publication.prNumber === null)
|
|
260
260
|
continue;
|
|
@@ -266,7 +266,7 @@ export async function observeChecks(store, options) {
|
|
|
266
266
|
const viewed = await exec("gh", [
|
|
267
267
|
"pr", "view", String(publication.prNumber),
|
|
268
268
|
"--repo", publication.githubRepo,
|
|
269
|
-
"--json", "statusCheckRollup,headRefOid,state",
|
|
269
|
+
"--json", "statusCheckRollup,headRefOid,state,mergeCommit",
|
|
270
270
|
], { timeoutMs: EXEC_TIMEOUT_MS });
|
|
271
271
|
if (viewed.code !== 0) {
|
|
272
272
|
report.problems.push(`PR #${publication.prNumber}: not read — ${viewed.notFound ? "gh is not on PATH" : firstLine(viewed.stderr) || `exit ${viewed.code}`}`);
|
|
@@ -290,11 +290,15 @@ export async function observeChecks(store, options) {
|
|
|
290
290
|
// remote state is recorded, its episodes resolve, and neither the
|
|
291
291
|
// review queue nor the next observation pass carries it forever.
|
|
292
292
|
const remoteState = typeof payload.state === "string" ? payload.state.toUpperCase() : null;
|
|
293
|
+
const mergeCommit = typeof payload.mergeCommit === "object" && payload.mergeCommit !== null && typeof payload.mergeCommit.oid === "string"
|
|
294
|
+
? payload.mergeCommit.oid : null;
|
|
293
295
|
if (remoteState === "MERGED" || remoteState === "CLOSED") {
|
|
296
|
+
let won = false;
|
|
294
297
|
store.transact(() => {
|
|
295
298
|
const settled = store.settleObservation({ githubRepo: publication.githubRepo, prNumber: publication.prNumber, headSha: headOid, state: "none", generation }, clock());
|
|
296
299
|
if (!settled.won)
|
|
297
300
|
return;
|
|
301
|
+
won = true;
|
|
298
302
|
store.recordPublicationRemoteState(publication.id, remoteState, clock());
|
|
299
303
|
report.resolved += store.resolveCiEpisodes(publication.githubRepo, publication.prNumber, null, clock());
|
|
300
304
|
// A closed PR moots its merge blocker and supersedes its intent —
|
|
@@ -311,6 +315,8 @@ export async function observeChecks(store, options) {
|
|
|
311
315
|
});
|
|
312
316
|
}
|
|
313
317
|
});
|
|
318
|
+
if (won)
|
|
319
|
+
report.seen.push({ publication, headOid, state: "none", remoteState, rollup: payload.statusCheckRollup, mergeCommit });
|
|
314
320
|
continue;
|
|
315
321
|
}
|
|
316
322
|
const state = summarizeChecks(payload.statusCheckRollup);
|
|
@@ -322,6 +328,7 @@ export async function observeChecks(store, options) {
|
|
|
322
328
|
continue;
|
|
323
329
|
}
|
|
324
330
|
store.recordPublicationCheckState(publication.id, state, clock());
|
|
331
|
+
report.seen.push({ publication, headOid, state, remoteState: null, rollup: payload.statusCheckRollup, mergeCommit });
|
|
325
332
|
// The episode identity carries the REPOSITORY (audit C-2): PR #55 in
|
|
326
333
|
// repo A and PR #55 in repo B are different worlds, and one's failure
|
|
327
334
|
// must never light the other's repair button.
|