toolroll 0.9.61 → 0.9.63
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 +46 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +31 -11
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +26 -12
- package/dist/landed-cleanup.js +199 -85
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +74 -17
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +1 -0
- package/dist/project-delete-ui.js +4 -1
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +61 -0
- package/dist/remote-main.js +179 -0
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +14 -5
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +14 -0
- package/dist/storage-sweep-process.js +37 -3
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +6 -5
- package/dist/storage-sweep.js +10 -70
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +7 -4
- package/dist/store.d.ts +75 -7
- package/dist/store.js +241 -17
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +10 -6
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +4 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +64 -6
- package/dist/worktree.js +171 -35
- package/package.json +3 -1
- package/scripts/provider-canary.mjs +210 -31
package/dist/operate.js
CHANGED
|
@@ -19,9 +19,11 @@ import { matchesOutsideCheckpoint } from "./ledger-chain.js";
|
|
|
19
19
|
import { CLEANUP_CHOICES, bytesWords, cleanupWords, diskBytes, parseCleanup, storageReport } from "./storage.js";
|
|
20
20
|
import { removeStaleTestTemp, tempRoots, testTempFolders } from "./test-temp.js";
|
|
21
21
|
import { storageSweepSupervisor } from "./storage-sweep-process.js";
|
|
22
|
-
import {
|
|
22
|
+
import { idleStorageSweep, sweepSchedule, sweepScheduleWords } from "./storage-sweep-schedule.js";
|
|
23
|
+
import { lastSweep, saveSweep, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
|
|
23
24
|
import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
|
|
24
25
|
import { cleanLandedBacklog, gitOf, landedBacklog, reconcileLanded, BACKLOG_DAYS } from "./landed-cleanup.js";
|
|
26
|
+
import { RemoteMains } from "./remote-main.js";
|
|
25
27
|
import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
|
|
26
28
|
import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
|
|
27
29
|
import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
|
|
@@ -50,6 +52,7 @@ import { readHooksBase, scheduleFromWords } from "./flow-triggers.js";
|
|
|
50
52
|
import { sendSubagentSummaries } from "./subagent-admin.js";
|
|
51
53
|
import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
|
|
52
54
|
import { refreshConnections } from "./mcp-connect.js";
|
|
55
|
+
import { SECRETS_FIRST, startToolSecrets, toolSecretStorage } from "./project-tools.js";
|
|
53
56
|
import { followDiscord } from "./discord.js";
|
|
54
57
|
import { followTeams } from "./teams.js";
|
|
55
58
|
import { followSlack } from "./slack.js";
|
|
@@ -182,6 +185,7 @@ import { createInterface } from "node:readline";
|
|
|
182
185
|
import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, fileAndSealUnderMode, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha } from "./scope.js";
|
|
183
186
|
import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
|
|
184
187
|
import { WorktreePool } from "./worktree.js";
|
|
188
|
+
import { baseStandingWords, freshBaseFor, taskBaseOf } from "./task-base.js";
|
|
185
189
|
import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
|
|
186
190
|
import { worktreeAdoptionNotice } from "./worktree-notices.js";
|
|
187
191
|
import { workIndexPage, workIndexTask, WorkIndexCursorError } from "./work-index.js";
|
|
@@ -812,6 +816,9 @@ async function runOperateLocal(command, argv, write, options = {}) {
|
|
|
812
816
|
catch (error) {
|
|
813
817
|
return databaseFailure(write, json, command, file, error);
|
|
814
818
|
}
|
|
819
|
+
// Toolroll's folder for the secrets it holds, beside this database: made before any build can run.
|
|
820
|
+
if (file !== ":memory:")
|
|
821
|
+
startToolSecrets(store, file, () => new Date(), { eager: SECRETS_FIRST.has(command) });
|
|
815
822
|
// The lead (a lead token, as --token or TOOLROLL_LEAD_TOKEN) acts for its owner in single commands; its work
|
|
816
823
|
// pings nobody. Anyone else who signs in inside one of these commands is who acted. Services name nobody.
|
|
817
824
|
const presentedFlag = text(flags, "token");
|
|
@@ -1052,6 +1059,19 @@ function telegramConversation(context, options = {}) {
|
|
|
1052
1059
|
phoneOrigin: () => phoneOrigin(process.env, dirname(context.databaseFile), { serverOrigin: options.serverOrigin ?? null }),
|
|
1053
1060
|
};
|
|
1054
1061
|
}
|
|
1062
|
+
/** The attempt's history says where it started when that is news: moved to current main, or kept on an older one. */
|
|
1063
|
+
function noteBaseStanding(store, runId, standing, main, now) {
|
|
1064
|
+
if (standing == null)
|
|
1065
|
+
return;
|
|
1066
|
+
const commits = (count) => `${count} commit${count === 1 ? "" : "s"}`;
|
|
1067
|
+
if (standing.moved !== null) {
|
|
1068
|
+
store.addRunNote(runId, "Toolroll", `Started from current ${main} (${standing.main.slice(0, 8)}). The branch had no commits or changes of its own and was ${commits(standing.moved.behind)} behind at ${standing.moved.from.slice(0, 8)}.`, now);
|
|
1069
|
+
}
|
|
1070
|
+
else if (standing.kept !== null && standing.behind > 0) {
|
|
1071
|
+
const why = standing.kept === "has commits" ? "it has commits of its own" : standing.kept === "has changes" ? "it has uncommitted changes" : "its checkout is not on the task branch";
|
|
1072
|
+
store.addRunNote(runId, "Toolroll", `Based on an older ${main}: ${standing.head.slice(0, 8)} is ${commits(standing.behind)} behind. It was not moved because ${why}.`, now);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1055
1075
|
/** A revision's verified start: its source build's sealed head, while the revision brief still binds that head and scope. */
|
|
1056
1076
|
function revisionSourceHead(store, evidenceRoot, taskRef, revisionOf) {
|
|
1057
1077
|
const source = store.revisionSourceOf(taskRef);
|
|
@@ -1924,6 +1944,7 @@ async function mcpCommand(file, flags, write, json) {
|
|
|
1924
1944
|
if (!door.ok)
|
|
1925
1945
|
return said(door.message);
|
|
1926
1946
|
const store = door.store;
|
|
1947
|
+
startToolSecrets(store, file, () => new Date(), { eager: false });
|
|
1927
1948
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
1928
1949
|
try {
|
|
1929
1950
|
chmodSync(`${file}${suffix}`, 0o600);
|
|
@@ -2728,6 +2749,8 @@ async function tickCommand(flags, context) {
|
|
|
2728
2749
|
// A plan branch from an earlier plan (its checkout removed by storage retention) is reused, as its kept checkout was.
|
|
2729
2750
|
reuseBranch: true,
|
|
2730
2751
|
reclaim: { evidenceRoot: context.evidenceRoot },
|
|
2752
|
+
// Each plan starts from current main unless it revises earlier work, which keeps that work's head.
|
|
2753
|
+
...freshBaseFor(store, id, planBranch, base),
|
|
2731
2754
|
});
|
|
2732
2755
|
if (!planLeased.ok) {
|
|
2733
2756
|
release(store, lease, clock());
|
|
@@ -2751,6 +2774,7 @@ async function tickCommand(flags, context) {
|
|
|
2751
2774
|
now: clock(),
|
|
2752
2775
|
...(planStamp === null ? {} : { route: planStamp }),
|
|
2753
2776
|
});
|
|
2777
|
+
noteBaseStanding(store, planRunId, planLeased.base, base, clock());
|
|
2754
2778
|
// The source is RECORDED on the attempt before the brief is even
|
|
2755
2779
|
// composed: the exact bytes the planner reads, sealed as evidence,
|
|
2756
2780
|
// and the filed scope's digest stamped as what this run was proved
|
|
@@ -3054,6 +3078,7 @@ async function tickCommand(flags, context) {
|
|
|
3054
3078
|
continue;
|
|
3055
3079
|
}
|
|
3056
3080
|
}
|
|
3081
|
+
// An existing branch with no commits of its own starts from current main (a new one already does).
|
|
3057
3082
|
const leased = await worktrees.lease({
|
|
3058
3083
|
repo,
|
|
3059
3084
|
branch,
|
|
@@ -3062,6 +3087,7 @@ async function tickCommand(flags, context) {
|
|
|
3062
3087
|
now: clock(),
|
|
3063
3088
|
...(exists.code === 0 ? {} : { base: buildBase }),
|
|
3064
3089
|
reclaim: { evidenceRoot: context.evidenceRoot },
|
|
3090
|
+
...(exists.code === 0 ? freshBaseFor(store, id, branch, base) : {}),
|
|
3065
3091
|
});
|
|
3066
3092
|
if (!leased.ok) {
|
|
3067
3093
|
// The task is fine; this machine's pool is not. Hand the claim back so
|
|
@@ -3122,6 +3148,7 @@ async function tickCommand(flags, context) {
|
|
|
3122
3148
|
dispatched.push({ id, outcome: "skipped", reason: "admission-refused", detail: error instanceof Error ? error.message : String(error) });
|
|
3123
3149
|
continue;
|
|
3124
3150
|
}
|
|
3151
|
+
noteBaseStanding(store, runId, leased.base, base, clock());
|
|
3125
3152
|
if (leased.resumedFromRun !== undefined) {
|
|
3126
3153
|
store.addRunNote(runId, "Toolroll", `Recovered the ${leased.recoveryKind === "completed" ? "completed source draft" : "work-in-progress draft"} from interrupted attempt #${leased.resumedFromRun}. This fresh attempt is reviewing and verifying it; the safety patch is retained.`, clock());
|
|
3127
3154
|
}
|
|
@@ -3448,10 +3475,15 @@ async function reconcileCommand(flags, context) {
|
|
|
3448
3475
|
// Landed work (landed-cleanup.ts): finish teardowns a crash cut short, then mark newly landed results complete (a
|
|
3449
3476
|
// ledger entry says why) and remove their clean checkouts and local build and plan branches.
|
|
3450
3477
|
let landed = { completed: [], steps: [] };
|
|
3478
|
+
// Completion trusts only the recorded remote's main, fetched by this process into a private folder it removes after.
|
|
3479
|
+
const remotes = new RemoteMains(store, context.gitRunner, context.evidenceRoot);
|
|
3451
3480
|
try {
|
|
3452
|
-
landed = await reconcileLanded(store, worktrees, gitOf(context.gitRunner), repo, context.evidenceRoot, clock);
|
|
3481
|
+
landed = await reconcileLanded(store, worktrees, gitOf(context.gitRunner), remotes, repo, context.evidenceRoot, clock);
|
|
3453
3482
|
}
|
|
3454
3483
|
catch { /* the next pass tries again */ }
|
|
3484
|
+
finally {
|
|
3485
|
+
remotes.close();
|
|
3486
|
+
}
|
|
3455
3487
|
const landedGone = landed.steps.filter(one => one.outcome === "removed");
|
|
3456
3488
|
// Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
|
|
3457
3489
|
// the task is complete or cancelled); their branches stay. Each removal is in the ledger.
|
|
@@ -3469,15 +3501,19 @@ async function reconcileCommand(flags, context) {
|
|
|
3469
3501
|
retention = null; /* the next pass tries again */
|
|
3470
3502
|
}
|
|
3471
3503
|
const retained = retention === null ? [] : retention.counts.filter(one => one.count > 0);
|
|
3472
|
-
// The
|
|
3504
|
+
// The storage sweep (storage-sweep.ts): stale test temp folders, orphans Toolroll left running, finished
|
|
3473
3505
|
// checkouts, extra staged runtimes and dependency installs unused for a week; saved for Settings → Storage.
|
|
3506
|
+
// Only the long-lived service (its watch passes `sweepOwner`) starts one, once due and while nothing is running or
|
|
3507
|
+
// waiting (storage-sweep-schedule.ts). A one-shot reconcile would exit and lose the claimed sweep, so it leaves it.
|
|
3474
3508
|
// Launch only: report a result here only if it already completed. Later results save independently.
|
|
3475
3509
|
let storageSwept = null;
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3510
|
+
if (context.sweepOwner !== undefined) {
|
|
3511
|
+
try {
|
|
3512
|
+
storageSwept = idleStorageSweep(store, { databaseFile: context.databaseFile, pool: worktrees, tempRoots: context.tempRoots ?? tempRoots(), owner: context.sweepOwner }, clock);
|
|
3513
|
+
}
|
|
3514
|
+
catch {
|
|
3515
|
+
storageSwept = null; /* the next pass tries again */
|
|
3516
|
+
}
|
|
3481
3517
|
}
|
|
3482
3518
|
const swept = storageSwept === null ? [] : storageSwept.parts.filter(one => one.count > 0);
|
|
3483
3519
|
const nothing = retained.length === 0 &&
|
|
@@ -6023,7 +6059,8 @@ async function runWatchLoop(args) {
|
|
|
6023
6059
|
if (!Number.isSafeInteger(reconcileEveryMs) || reconcileEveryMs < 1 || reconcileEveryMs > 2_147_483_647) {
|
|
6024
6060
|
throw new Error("--reconcile-every must be an integer from 1 to 2147483647 milliseconds");
|
|
6025
6061
|
}
|
|
6026
|
-
|
|
6062
|
+
// The owner carries lifetime without changing the lease schema. Bounded watches cannot host automatic sweeps.
|
|
6063
|
+
const incarnation = `${runFor === null ? "toolroll-service:" : ""}${randomUUID()}`;
|
|
6027
6064
|
const lease = acquireWatchLeaseAuthed(store, { runner, token, repo, owner: incarnation, ttlMs: WATCH_LEASE_MS }, new Date());
|
|
6028
6065
|
if (!lease.ok) {
|
|
6029
6066
|
return {
|
|
@@ -6158,7 +6195,9 @@ async function runWatchLoop(args) {
|
|
|
6158
6195
|
},
|
|
6159
6196
|
run: async () => {
|
|
6160
6197
|
const maintenanceLines = [];
|
|
6161
|
-
const maintenanceContext = { ...quietContext, sweepOwner, write: line => maintenanceLines.push(line) };
|
|
6198
|
+
const maintenanceContext = { ...quietContext, ...(runFor === null ? { sweepOwner } : {}), write: line => maintenanceLines.push(line) };
|
|
6199
|
+
if (runFor !== null)
|
|
6200
|
+
delete maintenanceContext.sweepOwner;
|
|
6162
6201
|
const code = await reconcileCommand(passFlags(), maintenanceContext);
|
|
6163
6202
|
if (code !== EXIT.ok)
|
|
6164
6203
|
throw new Error(maintenanceLines.join(" "));
|
|
@@ -7212,6 +7251,9 @@ async function upCommand(flags, context) {
|
|
|
7212
7251
|
}
|
|
7213
7252
|
const loopFlagsFor = (repo) => {
|
|
7214
7253
|
const copy = new Map();
|
|
7254
|
+
// A bounded `up` must not advertise its child watches as an unbounded sweep service either.
|
|
7255
|
+
if (forGiven !== undefined)
|
|
7256
|
+
copy.set("for", forGiven);
|
|
7215
7257
|
copy.set("runner", runnerName);
|
|
7216
7258
|
copy.set("token", runnerToken);
|
|
7217
7259
|
copy.set("repo", repo);
|
|
@@ -8443,7 +8485,8 @@ async function integrationsCommand(positional, flags, context) {
|
|
|
8443
8485
|
const counts = { connected: 0, "not-set-up": 0, broken: 0 };
|
|
8444
8486
|
for (const one of list)
|
|
8445
8487
|
counts[one.state] += 1;
|
|
8446
|
-
|
|
8488
|
+
const secrets = toolSecretStorage();
|
|
8489
|
+
return succeed(context.write, context.json, command, { integrations: list, counts, secrets: { kind: secrets.kind, words: secrets.words, ...(secrets.problem === undefined ? {} : { problem: secrets.problem }) } }, () => renderIntegrations(list, secrets));
|
|
8447
8490
|
}
|
|
8448
8491
|
async function waitTask(positional, flags, context) {
|
|
8449
8492
|
const command = "task wait";
|
|
@@ -9054,6 +9097,7 @@ function showTask(positional, context) {
|
|
|
9054
9097
|
const shared = listed === null ? null : { headline: listed.status.label, sentence: listed.status.detail };
|
|
9055
9098
|
const primaryAction = listed === null ? work?.primaryAction ?? null : listed.primaryAction;
|
|
9056
9099
|
const steeringLive = store.steeringLiveBuild(ref.id, now) !== null;
|
|
9100
|
+
const base = taskBaseOf(store, id);
|
|
9057
9101
|
const detail = {
|
|
9058
9102
|
task,
|
|
9059
9103
|
work: work === null || listed === null ? work : { ...work, status: { ...work.status, label: listed.status.label, detail: listed.status.detail, tone: listed.status.tone }, primaryAction },
|
|
@@ -9069,6 +9113,8 @@ function showTask(positional, context) {
|
|
|
9069
9113
|
scope,
|
|
9070
9114
|
approval: approvalOf(scope),
|
|
9071
9115
|
route: taskRouteView(routed, readiness),
|
|
9116
|
+
// Fresh base: the commit this task's work starts from, and how far current main has moved past it.
|
|
9117
|
+
base,
|
|
9072
9118
|
runs: runs.map(one => ({ ...one, route: store.runRoute(one.id) })),
|
|
9073
9119
|
deliverable: ref.deliverable,
|
|
9074
9120
|
report: readVerifiedReport(store, context.evidenceRoot, ref.id),
|
|
@@ -9125,6 +9171,7 @@ function showTask(positional, context) {
|
|
|
9125
9171
|
]
|
|
9126
9172
|
: [` report: ${detail.report.problem} (run ${detail.report.run})`]),
|
|
9127
9173
|
...pullRequestLines(detail.pullRequest),
|
|
9174
|
+
...(base === null ? [] : [` base: ${baseStandingWords(base)}`]),
|
|
9128
9175
|
...(detail.blockedBy.length > 0 ? [` waits for ${detail.blockedBy.join(", ")}`] : []),
|
|
9129
9176
|
...(detail.position === null
|
|
9130
9177
|
? []
|
|
@@ -11165,15 +11212,15 @@ async function storageCommand(positional, flags, context) {
|
|
|
11165
11212
|
const sweepFacts = () => {
|
|
11166
11213
|
const last = lastSweep(store);
|
|
11167
11214
|
const automatic = last?.source === "automatic" ? last : lastSweep(store, "automatic");
|
|
11168
|
-
return { last, lastAutomatic: automatic,
|
|
11215
|
+
return { last, lastAutomatic: automatic, schedule: sweepSchedule(store, context.clock()) };
|
|
11169
11216
|
};
|
|
11170
11217
|
const sweepLines = (facts) => {
|
|
11171
11218
|
const when = (at) => new Date(at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
11172
11219
|
const one = facts.last;
|
|
11173
|
-
const head = one === null ?
|
|
11220
|
+
const head = one === null ? "Not swept yet."
|
|
11174
11221
|
: `Last swept ${when(one.at)}${one.source === "manual" ? ` by ${one.actor}` : " automatically"}: ${storageSweepWords(one.parts)}`;
|
|
11175
11222
|
const auto = one !== null && one.source === "manual" && facts.lastAutomatic !== null ? [`Last automatic sweep ${when(facts.lastAutomatic.at)}: ${storageSweepWords(facts.lastAutomatic.parts)}`] : [];
|
|
11176
|
-
return [head, ...auto, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
|
|
11223
|
+
return [head, ...auto, `Automatic sweep: ${sweepScheduleWords(facts.schedule, when)}`, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
|
|
11177
11224
|
};
|
|
11178
11225
|
if (action === undefined) {
|
|
11179
11226
|
const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
|
|
@@ -11289,6 +11336,16 @@ async function storageCommand(positional, flags, context) {
|
|
|
11289
11336
|
* checkouts and local branches go; so do Toolroll's own branches no task uses whose work landed. A preview changes
|
|
11290
11337
|
* nothing; --yes takes an instance operator, records every teardown first, then carries it out. */
|
|
11291
11338
|
async function landedCommand(yes, operator, refused, pool, context) {
|
|
11339
|
+
// Completion trusts only the recorded remote's main, fetched by this process into a private folder it removes after.
|
|
11340
|
+
const remotes = new RemoteMains(context.store, context.gitRunner, context.evidenceRoot);
|
|
11341
|
+
try {
|
|
11342
|
+
return await landedCommandWith(remotes, yes, operator, refused, pool, context);
|
|
11343
|
+
}
|
|
11344
|
+
finally {
|
|
11345
|
+
remotes.close();
|
|
11346
|
+
}
|
|
11347
|
+
}
|
|
11348
|
+
async function landedCommandWith(remotes, yes, operator, refused, pool, context) {
|
|
11292
11349
|
const command = "storage clean --landed";
|
|
11293
11350
|
const git = gitOf(context.gitRunner);
|
|
11294
11351
|
const plurals = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
@@ -11300,7 +11357,7 @@ async function landedCommand(yes, operator, refused, pool, context) {
|
|
|
11300
11357
|
branches: plan.results.reduce((sum, one) => sum + one.targets.filter(target => target.kind === "branch").length, 0) + plan.branches.length,
|
|
11301
11358
|
});
|
|
11302
11359
|
if (!yes) {
|
|
11303
|
-
const plan = await landedBacklog(context.store, git, context.evidenceRoot, context.clock());
|
|
11360
|
+
const plan = await landedBacklog(context.store, git, remotes, context.evidenceRoot, context.clock());
|
|
11304
11361
|
const n = counts(plan);
|
|
11305
11362
|
const anything = n.results + plan.branches.length + plan.pending > 0;
|
|
11306
11363
|
return succeed(context.write, context.json, command, { preview: true, cutoff: plan.cutoff, results: plan.results, branches: plan.branches, kept: plan.kept, pending: plan.pending }, () => [
|
|
@@ -11315,8 +11372,8 @@ async function landedCommand(yes, operator, refused, pool, context) {
|
|
|
11315
11372
|
const actor = await operator();
|
|
11316
11373
|
if (actor === null)
|
|
11317
11374
|
return refused("cleans up landed work");
|
|
11318
|
-
const plan = await landedBacklog(context.store, git, context.evidenceRoot, context.clock());
|
|
11319
|
-
const done = await cleanLandedBacklog(context.store, pool, git, context.evidenceRoot, context.clock, plan, actor);
|
|
11375
|
+
const plan = await landedBacklog(context.store, git, remotes, context.evidenceRoot, context.clock());
|
|
11376
|
+
const done = await cleanLandedBacklog(context.store, pool, git, remotes, context.evidenceRoot, context.clock, plan, actor);
|
|
11320
11377
|
const gone = done.steps.filter(one => one.outcome === "removed" || one.outcome === "already gone");
|
|
11321
11378
|
const stays = done.steps.filter(one => one.outcome === "waits" || one.outcome === "kept");
|
|
11322
11379
|
return succeed(context.write, context.json, command, { completed: done.completed, steps: done.steps, refused: done.refused, kept: plan.kept }, () => [
|
package/dist/plan-auto.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
|
+
import { type UnattendedGrant } from "./flow-unattended.js";
|
|
2
3
|
export declare const PLAN_AUTO_SCHEMA = "CREATE TABLE IF NOT EXISTS plan_authorization (\n task_id TEXT PRIMARY KEY REFERENCES task(id),\n source_digest TEXT NOT NULL,\n scope_digest TEXT NOT NULL,\n mode_digest TEXT NOT NULL,\n signed_by TEXT NOT NULL,\n created_at TEXT NOT NULL\n);";
|
|
3
4
|
/** Join the console's authority-free proposal to the authenticated filing
|
|
4
5
|
* road in one transaction. Planning defers the seal until verified ingestion. */
|
|
@@ -7,6 +8,16 @@ export declare function applyModeToNewFiling(store: Store, taskId: string, actor
|
|
|
7
8
|
* code. This authorizes the exact filed contract, not arbitrary future
|
|
8
9
|
* planner text. Rewrites, mode renewal, expiry and revocation fail closed. */
|
|
9
10
|
export declare function authorizePlanUnderMode(store: Store, taskId: string, actor: string, now: Date): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* A flow card's new filing under its flow's "runs on its own" grant (flow-unattended.ts), inside the engine's filing
|
|
13
|
+
* transaction, which has just proved the grant covers this card. A plain build that plans is pre-authorized for an
|
|
14
|
+
* unchanged plan only; anything else is sealed now, under a distinct `grant` basis. False (the filing then waits for a
|
|
15
|
+
* person, as it always did) when the filed terms aren't the safe, fixed ones the grant was signed for.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sealFlowFilingUnderGrant(store: Store, taskId: string, grant: UnattendedGrant, card: {
|
|
18
|
+
flow: number;
|
|
19
|
+
id: number;
|
|
20
|
+
}, now: Date): boolean;
|
|
10
21
|
/** Read-side status only; finalization still proves the authority anew. */
|
|
11
22
|
export declare function planAutoPending(store: Store, taskId: string, now: Date): boolean;
|
|
12
23
|
/** Inside the verified planner finalization transaction, after the source,
|
package/dist/plan-auto.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { releaseUnattendedRun, reserveUnattendedRun, unattendedGrantStands } from "./flow-unattended.js";
|
|
1
2
|
import { modeTermsFromJson } from "./modes.js";
|
|
2
3
|
import { plannerContractOf, plannerSourceDigest } from "./planner-source.js";
|
|
3
4
|
import { modeFilingCoverage, propose } from "./scope.js";
|
|
@@ -49,13 +50,68 @@ export function authorizePlanUnderMode(store, taskId, actor, now) {
|
|
|
49
50
|
const scope = source.contract.scope;
|
|
50
51
|
if (scope.proposedVia !== null || scope.acceptance.length === 0 || scope.touches.length === 0 || scope.terms.profileState !== "resolved" || store.scopeSealed(taskId))
|
|
51
52
|
return false;
|
|
52
|
-
store.raw().prepare(`INSERT INTO plan_authorization(task_id,source_digest,scope_digest,mode_digest,signed_by,created_at) VALUES (
|
|
53
|
-
ON CONFLICT(task_id) DO UPDATE SET source_digest=excluded.source_digest,scope_digest=excluded.scope_digest,mode_digest=excluded.mode_digest,signed_by=excluded.signed_by,created_at=excluded.created_at`)
|
|
53
|
+
store.raw().prepare(`INSERT INTO plan_authorization(task_id,source_digest,scope_digest,mode_digest,signed_by,created_at,basis) VALUES (?,?,?,?,?,?,'mode')
|
|
54
|
+
ON CONFLICT(task_id) DO UPDATE SET source_digest=excluded.source_digest,scope_digest=excluded.scope_digest,mode_digest=excluded.mode_digest,signed_by=excluded.signed_by,created_at=excluded.created_at,basis='mode'`)
|
|
54
55
|
.run(taskId, plannerSourceDigest(source.contract), scope.digest, mode.digest, actor, now.toISOString());
|
|
55
56
|
store.recordAction({ at: now.toISOString(), actor, repo: ref.repo, taskId, runId: null, action: "unchanged plan pre-authorized", outcome: "authorized", source: "work" });
|
|
56
57
|
return true;
|
|
57
58
|
});
|
|
58
59
|
}
|
|
60
|
+
/** A scope whose resolved profile or route runs with full permissions: a flow grant never seals one. */
|
|
61
|
+
function escalatedPosture(store, taskId) {
|
|
62
|
+
const row = store.raw().prepare("SELECT profile_json, proposed_route_json FROM task_scope WHERE task_id = ?").get(taskId);
|
|
63
|
+
const text = `${row?.["profile_json"] ?? ""} ${row?.["proposed_route_json"] ?? ""}`;
|
|
64
|
+
return /"(?:bypassPermissions|danger-full-access|yolo)"/.test(text);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A flow card's new filing under its flow's "runs on its own" grant (flow-unattended.ts), inside the engine's filing
|
|
68
|
+
* transaction, which has just proved the grant covers this card. A plain build that plans is pre-authorized for an
|
|
69
|
+
* unchanged plan only; anything else is sealed now, under a distinct `grant` basis. False (the filing then waits for a
|
|
70
|
+
* person, as it always did) when the filed terms aren't the safe, fixed ones the grant was signed for.
|
|
71
|
+
*/
|
|
72
|
+
export function sealFlowFilingUnderGrant(store, taskId, grant, card, now) {
|
|
73
|
+
const ref = store.lookupRef(taskId);
|
|
74
|
+
const scope = store.getScope(taskId);
|
|
75
|
+
if (ref === null || scope === null || scope.proposedVia != null || scope.profileState !== "resolved" || escalatedPosture(store, taskId))
|
|
76
|
+
return false;
|
|
77
|
+
if (!unattendedGrantStands(store, grant.digest, grant.signedBy, ref.repo, scope.touches, now))
|
|
78
|
+
return false;
|
|
79
|
+
// The day's run is taken first, in one capped statement; a filing that then can't be sealed gives it back.
|
|
80
|
+
if (!reserveUnattendedRun(store, grant, taskId, now))
|
|
81
|
+
return false;
|
|
82
|
+
let done = false;
|
|
83
|
+
if (ref.plan === "requested") {
|
|
84
|
+
const source = plannerContractOf(store, taskId);
|
|
85
|
+
if (plainPlan(store, taskId) && source.ok && source.contract.scope !== null && source.contract.revision === null && source.contract.scope.acceptance.length > 0) {
|
|
86
|
+
store.raw().prepare(`INSERT INTO plan_authorization(task_id,source_digest,scope_digest,mode_digest,signed_by,created_at,basis) VALUES (?,?,?,?,?,?,'grant')
|
|
87
|
+
ON CONFLICT(task_id) DO UPDATE SET source_digest=excluded.source_digest,scope_digest=excluded.scope_digest,mode_digest=excluded.mode_digest,signed_by=excluded.signed_by,created_at=excluded.created_at,basis='grant'`)
|
|
88
|
+
.run(taskId, plannerSourceDigest(source.contract), source.contract.scope.digest, grant.digest, grant.signedBy, now.toISOString());
|
|
89
|
+
done = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
done = store.sealScopeApproval(taskId, grant.signedBy, now, {}, { kind: "grant", modeDigest: grant.digest });
|
|
94
|
+
}
|
|
95
|
+
if (!done) {
|
|
96
|
+
releaseUnattendedRun(store, grant, taskId);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
store.recordAction({ at: now.toISOString(), actor: grant.signedBy, repo: ref.repo, taskId, runId: null, source: "work",
|
|
100
|
+
action: ref.plan === "requested" ? "flow ran on its own: unchanged plan pre-authorized" : "flow ran on its own: approved", outcome: "authorized", detail: `flow #${card.flow} · card ${card.id}` });
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/** Whether the authority that armed this plan authorization still stands (read side and finalization alike). */
|
|
104
|
+
function armedAuthorityStands(store, row, repo, now) {
|
|
105
|
+
const by = String(row["signed_by"]), digest = String(row["mode_digest"]);
|
|
106
|
+
if (row["basis"] === "grant") {
|
|
107
|
+
const scope = store.getScope(String(row["task_id"]));
|
|
108
|
+
return unattendedGrantStands(store, digest, by, repo, scope?.touches ?? [], now) ? { basis: "grant", digest } : null;
|
|
109
|
+
}
|
|
110
|
+
const mode = store.activeMode(repo, now);
|
|
111
|
+
const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
|
|
112
|
+
return mode !== null && terms?.planAuto === true && terms.autoApproveFiling && mode.digest === digest && mode.signedBy === by && store.accountCanAccess(by, repo)
|
|
113
|
+
? { basis: "mode", digest } : null;
|
|
114
|
+
}
|
|
59
115
|
/** Read-side status only; finalization still proves the authority anew. */
|
|
60
116
|
export function planAutoPending(store, taskId, now) {
|
|
61
117
|
const ref = store.lookupRef(taskId);
|
|
@@ -64,11 +120,8 @@ export function planAutoPending(store, taskId, now) {
|
|
|
64
120
|
const row = store.raw().prepare("SELECT * FROM plan_authorization WHERE task_id=?").get(taskId);
|
|
65
121
|
if (row === undefined)
|
|
66
122
|
return false;
|
|
67
|
-
const mode = store.activeMode(ref.repo, now);
|
|
68
|
-
const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
|
|
69
123
|
const source = plannerContractOf(store, taskId);
|
|
70
|
-
return
|
|
71
|
-
store.accountCanAccess(mode.signedBy, ref.repo) && source.ok && plannerSourceDigest(source.contract) === row["source_digest"];
|
|
124
|
+
return armedAuthorityStands(store, row, ref.repo, now) !== null && source.ok && plannerSourceDigest(source.contract) === row["source_digest"];
|
|
72
125
|
}
|
|
73
126
|
/** Inside the verified planner finalization transaction, after the source,
|
|
74
127
|
* artifact, contract changes and lease fence have all been checked. */
|
|
@@ -80,13 +133,11 @@ export function sealUnchangedPlanUnderMode(store, taskId, sourceDigest, runId, e
|
|
|
80
133
|
store.raw().prepare("DELETE FROM plan_authorization WHERE task_id = ?").run(taskId);
|
|
81
134
|
const ref = store.lookupRef(taskId);
|
|
82
135
|
const scope = store.getScope(taskId);
|
|
83
|
-
const mode = ref?.repo == null ? null : store.activeMode(ref.repo, now);
|
|
84
|
-
const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
|
|
85
136
|
const by = String(authorization["signed_by"]);
|
|
137
|
+
const authority = ref?.repo == null ? null : armedAuthorityStands(store, authorization, ref.repo, now);
|
|
86
138
|
const covered = eligible && plainPlan(store, taskId) && sourceDigest === authorization["source_digest"] && scope?.digest === authorization["scope_digest"] &&
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const approved = covered && store.sealScopeApproval(taskId, by, now, {}, { kind: "mode", modeDigest: mode.digest });
|
|
139
|
+
authority !== null && (authority.basis === "mode" || !escalatedPosture(store, taskId));
|
|
140
|
+
const approved = covered && store.sealScopeApproval(taskId, by, now, {}, { kind: authority.basis, modeDigest: authority.digest });
|
|
90
141
|
store.recordAction({ at: now.toISOString(), actor: by, repo: ref?.repo ?? null, taskId, runId, action: "plan auto-approval", outcome: approved ? "approved" : "needs approval", source: "work" });
|
|
91
142
|
return approved;
|
|
92
143
|
}
|
|
@@ -50,7 +50,7 @@ export declare function preparedCandidateObserverGapEligibility(store: Store, in
|
|
|
50
50
|
digest: string;
|
|
51
51
|
approvedAt: string;
|
|
52
52
|
approvedBy: string;
|
|
53
|
-
approvalBasis: "chat" | "mode" | "password";
|
|
53
|
+
approvalBasis: "chat" | "grant" | "mode" | "password";
|
|
54
54
|
modeDigest: string | null;
|
|
55
55
|
};
|
|
56
56
|
readonly command: {
|
package/dist/project-access.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export declare function normalizeProjectAccess(value: unknown): string[] | null;
|
|
|
5
5
|
/** Corrupt or missing authority never falls back to instance access. */
|
|
6
6
|
export declare function readProjectAccess(value: unknown): ProjectAccess;
|
|
7
7
|
export declare function projectAccessAllows(access: ProjectAccess, repo: string | null): boolean;
|
|
8
|
+
/** SQL: projectAccessAllows for an `approver` row (`alias`) and a project path expression, for the dispatch belts. A
|
|
9
|
+
* null list reaches every project; an unreadable one reaches none. */
|
|
10
|
+
export declare const accountReachesSql: (alias: string, repo: string) => string;
|
|
8
11
|
/** A server-proved resource, never an unchecked header or submitted path.
|
|
9
12
|
* CLI ceremonies must pass their resource explicitly; global ceremonies
|
|
10
13
|
* without a project refuse project-scoped credentials. */
|
package/dist/project-access.js
CHANGED
|
@@ -28,6 +28,9 @@ export function readProjectAccess(value) {
|
|
|
28
28
|
export function projectAccessAllows(access, repo) {
|
|
29
29
|
return access === null || (repo !== null && access.includes(canonicalProject(repo) ?? resolve(repo)));
|
|
30
30
|
}
|
|
31
|
+
/** SQL: projectAccessAllows for an `approver` row (`alias`) and a project path expression, for the dispatch belts. A
|
|
32
|
+
* null list reaches every project; an unreadable one reaches none. */
|
|
33
|
+
export const accountReachesSql = (alias, repo) => `(${alias}.projects_json IS NULL OR (json_valid(${alias}.projects_json) AND EXISTS (SELECT 1 FROM json_each(${alias}.projects_json) WHERE json_each.value = ${repo})))`;
|
|
31
34
|
/** A server-proved resource, never an unchecked header or submitted path.
|
|
32
35
|
* CLI ceremonies must pass their resource explicitly; global ceremonies
|
|
33
36
|
* without a project refuse project-scoped credentials. */
|
|
@@ -24,11 +24,16 @@ export type AdmissionFailure = {
|
|
|
24
24
|
reason: "unavailable" | "not-git" | "locked" | "registry" | "abandoned" | "failed";
|
|
25
25
|
message: string;
|
|
26
26
|
};
|
|
27
|
+
/** `remote`: the origin URL this add recorded as the project's trusted remote (null: none), and why one wasn't. */
|
|
27
28
|
export type Admitted = {
|
|
28
29
|
ok: true;
|
|
29
30
|
repo: string;
|
|
30
31
|
name: string;
|
|
31
32
|
added: boolean;
|
|
33
|
+
remote: {
|
|
34
|
+
url: string | null;
|
|
35
|
+
why: string | null;
|
|
36
|
+
};
|
|
32
37
|
};
|
|
33
38
|
export type Released = {
|
|
34
39
|
ok: true;
|
|
@@ -43,12 +48,7 @@ export declare function proveProjectRoot(path: string, exec?: ProjectExec): Prom
|
|
|
43
48
|
ok: true;
|
|
44
49
|
repo: string;
|
|
45
50
|
} | AdmissionFailure>;
|
|
46
|
-
/**
|
|
47
|
-
* Admit one project. Idempotent: adding a saved project again changes
|
|
48
|
-
* nothing and writes no ledger row. The registry is written first, under
|
|
49
|
-
* its lock; the project row and its ledger row then commit together, so a
|
|
50
|
-
* retry after a failure finishes the job and records it exactly once.
|
|
51
|
-
*/
|
|
51
|
+
/** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
|
|
52
52
|
export declare function admitProject(store: Store, input: {
|
|
53
53
|
/** null: a console with no machine registry (a bare `serve`) keeps only the project list. */
|
|
54
54
|
registryFile: string | null;
|
|
@@ -56,6 +56,10 @@ export declare function admitProject(store: Store, input: {
|
|
|
56
56
|
actor: AdmissionActor;
|
|
57
57
|
now: Date;
|
|
58
58
|
exec?: ProjectExec;
|
|
59
|
+
/** Only the authorized CLI add opts in; console/open/onboarding never discover a remote. */
|
|
60
|
+
captureOrigin?: boolean;
|
|
61
|
+
/** Explicit value from the owner/instance-operator CLI boundary. */
|
|
62
|
+
remoteUrl?: string;
|
|
59
63
|
}): Promise<Admitted | AdmissionFailure>;
|
|
60
64
|
/**
|
|
61
65
|
* Remove one project from the lists and the builder. Its tasks, results and
|
|
@@ -15,7 +15,12 @@ import { userInfo } from "node:os";
|
|
|
15
15
|
import { dirname, join, resolve } from "node:path";
|
|
16
16
|
import { run } from "./exec.js";
|
|
17
17
|
import { canonicalProject, projectName, sameRepo } from "./project.js";
|
|
18
|
+
import { remoteUrlProblem } from "./remote-main.js";
|
|
18
19
|
import { addRepos, loadProjectRegistry, removeRepos, updateRepos } from "./repos.js";
|
|
20
|
+
const ADMISSION_GIT = {
|
|
21
|
+
envAllowlist: ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"],
|
|
22
|
+
env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1" },
|
|
23
|
+
};
|
|
19
24
|
export const PROJECT_ADDED = "project added";
|
|
20
25
|
export const PROJECT_REMOVED = "project removed";
|
|
21
26
|
/** The checkout root a folder belongs to, canonical; never a shell. */
|
|
@@ -26,40 +31,57 @@ export async function proveProjectRoot(path, exec = run) {
|
|
|
26
31
|
const canonical = canonicalProject(path);
|
|
27
32
|
if (canonical === null)
|
|
28
33
|
return { ok: false, reason: "unavailable", message: `${path} isn't a folder on this computer.` };
|
|
29
|
-
const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000 });
|
|
34
|
+
const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000, ...ADMISSION_GIT });
|
|
30
35
|
const repo = top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
|
|
31
36
|
if (repo === null)
|
|
32
37
|
return { ok: false, reason: "not-git", message: `${path} isn't a Git repository. Run git init there first, or choose a folder inside one.` };
|
|
33
38
|
return { ok: true, repo };
|
|
34
39
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
/** Read only on first CLI admission; never rediscover trust from an existing project's config. */
|
|
41
|
+
async function originOf(repo, exec) {
|
|
42
|
+
const read = await exec("git", ["config", "--local", "--no-includes", "--get", "remote.origin.url"], { cwd: repo, timeoutMs: 5_000, ...ADMISSION_GIT });
|
|
43
|
+
const url = read.code === 0 ? read.stdout.trim() : "";
|
|
44
|
+
if (url === "")
|
|
45
|
+
return { url: null, why: "it has no origin remote" };
|
|
46
|
+
const problem = remoteUrlProblem(url);
|
|
47
|
+
return problem === null ? { url, why: null } : { url: null, why: problem };
|
|
48
|
+
}
|
|
49
|
+
/** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
|
|
41
50
|
export async function admitProject(store, input) {
|
|
42
51
|
const proved = await proveProjectRoot(input.path, input.exec);
|
|
43
52
|
if (!proved.ok)
|
|
44
53
|
return proved;
|
|
45
54
|
const { repo } = proved;
|
|
55
|
+
if (input.remoteUrl !== undefined && (input.actor.origin === "console" || remoteUrlProblem(input.remoteUrl) !== null)) {
|
|
56
|
+
return { ok: false, reason: "failed", message: "Only an owner or instance operator can record a network remote with repos add --remote <url>. Use an HTTPS or SSH URL without credentials." };
|
|
57
|
+
}
|
|
58
|
+
const before = input.registryFile === null ? null : await loadProjectRegistry(input.registryFile);
|
|
59
|
+
const known = store.projectAdmissionKnown(repo) || (before !== null && !("error" in before) && before.repos.includes(repo));
|
|
60
|
+
const discovered = !known && input.captureOrigin === true && input.actor.origin !== "console"
|
|
61
|
+
? await originOf(repo, input.exec ?? run) : null;
|
|
62
|
+
let remote = { url: store.projectRemote(repo), why: null };
|
|
46
63
|
let registryHad = true;
|
|
47
64
|
if (input.registryFile !== null) {
|
|
48
|
-
const before = await loadProjectRegistry(input.registryFile);
|
|
49
65
|
const enrolled = await updateRepos(input.registryFile, repos => addRepos(repos, [repo]));
|
|
50
66
|
if (!enrolled.ok)
|
|
51
67
|
return { ok: false, reason: enrolled.reason, message: `${projectName(repo)} wasn't added: ${enrolled.message}` };
|
|
52
|
-
registryHad = !("error" in before) && before.repos.includes(repo);
|
|
68
|
+
registryHad = before !== null && !("error" in before) && before.repos.includes(repo);
|
|
53
69
|
}
|
|
54
70
|
const added = store.transact(() => {
|
|
55
71
|
const rowHad = store.listProjects().some(one => one.path === repo);
|
|
56
|
-
store.
|
|
72
|
+
const previous = store.projectRemote(repo);
|
|
73
|
+
const first = !known && !store.projectAdmissionKnown(repo);
|
|
74
|
+
remote = input.remoteUrl !== undefined ? { url: input.remoteUrl, why: null }
|
|
75
|
+
: first && discovered !== null ? discovered : { url: previous, why: previous === null ? "no remote recorded; use repos add --remote <url>" : null };
|
|
76
|
+
store.upsertProject(repo, projectName(repo), input.now, { remoteUrl: remote.url });
|
|
77
|
+
if (input.remoteUrl !== undefined)
|
|
78
|
+
store.recordProjectRemote(input.actor.label, repo, previous, input.remoteUrl, input.now);
|
|
57
79
|
if (rowHad && registryHad)
|
|
58
80
|
return false;
|
|
59
81
|
store.recordAction({ at: input.now.toISOString(), actor: input.actor.label, repo, taskId: null, runId: null, action: PROJECT_ADDED, outcome: "added", source: "access", detail: `from ${input.actor.origin}` });
|
|
60
82
|
return true;
|
|
61
83
|
});
|
|
62
|
-
return { ok: true, repo, name: projectName(repo), added };
|
|
84
|
+
return { ok: true, repo, name: projectName(repo), added, remote };
|
|
63
85
|
}
|
|
64
86
|
/**
|
|
65
87
|
* Remove one project from the lists and the builder. Its tasks, results and
|
|
@@ -8,6 +8,7 @@ import { type Html } from "./html.js";
|
|
|
8
8
|
import { type ProjectHoldings } from "./project-delete.js";
|
|
9
9
|
export declare const PROJECT_DELETE_CSS: string;
|
|
10
10
|
export type ProjectSettingsView = {
|
|
11
|
+
remoteUrl?: string | null;
|
|
11
12
|
repo: string;
|
|
12
13
|
name: string;
|
|
13
14
|
holdings: ProjectHoldings;
|
|
@@ -57,7 +57,10 @@ const note = (notice) => notice.problem ? html `<p class="problem" role="alert">
|
|
|
57
57
|
/** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
|
|
58
58
|
export function projectSettingsHtml(view, notice) {
|
|
59
59
|
const held = html `<p>Toolroll holds ${holdingsWords(view.holdings)} for <strong>${view.name}</strong>.</p>`;
|
|
60
|
-
const
|
|
60
|
+
const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
|
|
61
|
+
? html `<p class="path">${view.remoteUrl}</p>`
|
|
62
|
+
: html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add <project> --remote <url></p></details></section>`;
|
|
63
|
+
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}`;
|
|
61
64
|
if (!view.canDelete)
|
|
62
65
|
return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
|
|
63
66
|
const body = view.running.length > 0
|
package/dist/project-delete.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ExecResult } from "./exec.js";
|
|
2
|
+
import type { Runner } from "./worktree.js";
|
|
2
3
|
import { type Store } from "./store.js";
|
|
3
4
|
/** What Toolroll names the branches it makes: toolroll/…, and standing-orders/… from before the rename. */
|
|
4
5
|
export declare const OWN_BRANCH = "toolroll/";
|
|
@@ -52,4 +53,5 @@ export declare function deleteProject(store: Store, repo: string, options: {
|
|
|
52
53
|
evidenceRoot: string | null;
|
|
53
54
|
poolRoot: string | null;
|
|
54
55
|
git?: GitRunner;
|
|
56
|
+
runner?: Runner;
|
|
55
57
|
}): Promise<ProjectDeleteResult>;
|