toolroll 0.9.88 → 0.9.89
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 +21 -0
- package/dist/browser/terminal.js +10 -10
- package/dist/browser/workspace.js +28 -28
- package/dist/chat-channel.js +11 -1
- package/dist/egress.js +5 -0
- package/dist/flow-starters.js +3 -0
- package/dist/flow-triggers.js +4 -0
- package/dist/folder-gate.d.ts +32 -0
- package/dist/folder-gate.js +43 -0
- package/dist/project-access.js +15 -1
- package/dist/project-admission.d.ts +6 -1
- package/dist/project-admission.js +44 -7
- package/dist/project-identity.d.ts +8 -0
- package/dist/project-identity.js +43 -0
- package/dist/project.d.ts +1 -0
- package/dist/project.js +6 -0
- package/dist/proposal.js +11 -1
- package/dist/recipes.js +12 -1
- package/dist/serve.js +28 -6
- package/dist/server/pages.js +2 -2
- package/dist/server/render-tasks.js +4 -2
- package/dist/setup-guide.js +6 -0
- package/dist/teardown-git.js +16 -1
- package/dist/youiyoux-intake.js +11 -1
- package/package.json +1 -1
package/dist/chat-channel.js
CHANGED
|
@@ -12,11 +12,21 @@ import { canonicalProject } from "./project.js";
|
|
|
12
12
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
13
13
|
import { CHAT_CONTROLS, chatControlHref, isChatControl, } from "./chat-controls.js";
|
|
14
14
|
import { CHAT_TASK_ACTIONS, isChatTaskAction } from "./chat-task-actions.js";
|
|
15
|
+
import { FolderUnavailable } from "./folder-gate.js";
|
|
15
16
|
export function channelRepos(store, approver, registry) {
|
|
16
17
|
const seen = new Set();
|
|
17
18
|
const repos = [];
|
|
18
19
|
for (const path of registry) {
|
|
19
|
-
|
|
20
|
+
let canonical;
|
|
21
|
+
// A folder the console's gate holds back (folder-gate.ts) is left out, never matched by its spelling.
|
|
22
|
+
try {
|
|
23
|
+
canonical = canonicalProject(path) ?? path;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof FolderUnavailable)
|
|
27
|
+
continue;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
20
30
|
if (seen.has(canonical) || !store.accountCanAccess(approver, canonical))
|
|
21
31
|
continue;
|
|
22
32
|
seen.add(canonical);
|
package/dist/egress.js
CHANGED
|
@@ -18,6 +18,7 @@ import { readdirSync } from "node:fs";
|
|
|
18
18
|
import { basename, join } from "node:path";
|
|
19
19
|
import { connectFirst, isPublicAddress } from "./scout-net.js";
|
|
20
20
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
21
|
+
import { folderAnswers } from "./folder-gate.js";
|
|
21
22
|
/** The policy's shape version: a worker enforces the versions it knows and refuses any other. */
|
|
22
23
|
export const NETWORK_POLICY_VERSION = 1;
|
|
23
24
|
export const PROJECT_NETWORK_DEFAULT = { mode: "allowlist", hosts: [], loopback: "proxy-only" };
|
|
@@ -114,6 +115,10 @@ const WALK_SKIP = new Set(["node_modules", "vendor", "target", "dist", "build",
|
|
|
114
115
|
* project's own hosts only.
|
|
115
116
|
*/
|
|
116
117
|
export function repositoryEcosystems(repo) {
|
|
118
|
+
// In the console, a folder that hasn't answered a look lately is not walked on its thread (folder-gate.ts): it names
|
|
119
|
+
// none, as an unreadable folder does.
|
|
120
|
+
if (!folderAnswers(repo))
|
|
121
|
+
return [];
|
|
117
122
|
const paths = [];
|
|
118
123
|
const walk = (folder, depth) => {
|
|
119
124
|
let entries;
|
package/dist/flow-starters.js
CHANGED
|
@@ -10,6 +10,7 @@ import { flowDefinitionOf } from "./flow-engine.js";
|
|
|
10
10
|
import { addFlowTriggerTo, githubRepoOf } from "./flow-triggers.js";
|
|
11
11
|
import { flowFromSteps, ISSUE_LABEL } from "./flows.js";
|
|
12
12
|
import { publishingOf } from "./pull-request-flow.js";
|
|
13
|
+
import { folderAnswers } from "./folder-gate.js";
|
|
13
14
|
export const STARTER_IDS = ["ci-fix", "issue-task", "overnight", "plane-review"];
|
|
14
15
|
export const STARTER_FLOWS = [
|
|
15
16
|
{
|
|
@@ -87,6 +88,8 @@ export function planeReviewSteps(own) {
|
|
|
87
88
|
}
|
|
88
89
|
/** Whether a project is Toolroll's own source. */
|
|
89
90
|
export function isToolrollRepo(repo) {
|
|
91
|
+
if (!folderAnswers(repo))
|
|
92
|
+
return false;
|
|
90
93
|
try {
|
|
91
94
|
return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).name === "toolroll";
|
|
92
95
|
}
|
package/dist/flow-triggers.js
CHANGED
|
@@ -52,6 +52,7 @@ import { envValue } from "./names.js";
|
|
|
52
52
|
import { recordBranchCheckRun, unprovenNoticeRuns } from "./tidy.js";
|
|
53
53
|
import { linearLabelNames, readFormSubmission, readGithubEvent, readGithubIssue, readGithubIssueEventList, readGithubIssueList, readGithubRun, readGithubRunList, readInboundMail, readLinearAnswer, readLinearEvent, readLinearIssue, readWebhookPayload } from "./contracts/trigger-payloads.js";
|
|
54
54
|
import { isRecord } from "./is-record.js";
|
|
55
|
+
import { folderAnswers } from "./folder-gate.js";
|
|
55
56
|
export { FLOW_TRIGGER_KINDS };
|
|
56
57
|
export const CHAT_APP_NAMES = { slack: "Slack", discord: "Discord", teams: "Teams", telegram: "Telegram" };
|
|
57
58
|
/** What each kind is called on the canvas. */
|
|
@@ -124,6 +125,9 @@ export function scheduleFromWords(text) {
|
|
|
124
125
|
}
|
|
125
126
|
/** A GitHub repository as owner/name, read from a project's origin remote when it is on GitHub. */
|
|
126
127
|
export function githubRepoOf(path) {
|
|
128
|
+
// In the console, a folder that hasn't answered a look lately is not read on its thread (folder-gate.ts).
|
|
129
|
+
if (!folderAnswers(path))
|
|
130
|
+
return null;
|
|
127
131
|
try {
|
|
128
132
|
const dot = join(path, ".git");
|
|
129
133
|
const config = statSync(dot).isDirectory() ? readFileSync(join(dot, "config"), "utf8") : null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the console may read a folder synchronously right now.
|
|
3
|
+
*
|
|
4
|
+
* The console answers every page on one thread, so one synchronous read of a folder that has stopped answering (a
|
|
5
|
+
* drive that hangs, a folder macOS holds in iCloud) freezes the console with it: it listens and answers nothing, not
|
|
6
|
+
* even /healthz (the 0.9.82 and 0.9.87 deploys). So the console's own work (its request handling and its background
|
|
7
|
+
* pass, serve.ts) runs inside a folder scope: there, a synchronous read of a person's folder goes ahead only when that
|
|
8
|
+
* folder answered an off-thread look lately (project-identity.ts `answering`), or it lies in the console's own data.
|
|
9
|
+
* Otherwise the read isn't made and its caller refuses, says the folder is unavailable, or throws FolderUnavailable.
|
|
10
|
+
*
|
|
11
|
+
* Availability never decides authorization: a held-back canonicalization throws FolderUnavailable (project.ts), never
|
|
12
|
+
* answers null, so no caller falls back to a path's spelling because a folder didn't answer.
|
|
13
|
+
*
|
|
14
|
+
* Work outside a console scope (the worker, even in the same process as the console; `up`; the CLI) reads as it
|
|
15
|
+
* always did.
|
|
16
|
+
*/
|
|
17
|
+
/** One console's answer for a resolved path. `pointer`: the path came from a file a build can write (a checkout's
|
|
18
|
+
* `.git` or `commondir`), so the console's own folders (build checkouts among them) don't vouch for it by spelling. */
|
|
19
|
+
export type FolderGate = (path: string, pointer: boolean) => boolean;
|
|
20
|
+
/** What a caller says, and throws, when the gate holds a read back. */
|
|
21
|
+
export declare const FOLDER_DIDNT_ANSWER = "the folder didn't answer";
|
|
22
|
+
/** A synchronous read the console's folder gate held back: the folder is unavailable now, which is never a "no". */
|
|
23
|
+
export declare class FolderUnavailable extends Error {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
constructor(path: string);
|
|
26
|
+
}
|
|
27
|
+
/** Runs `work` (and everything it starts) inside the console's folder scope. */
|
|
28
|
+
export declare function inFolderScope<T>(gate: FolderGate, work: () => T): T;
|
|
29
|
+
/** Whether a synchronous read of `path` may happen now. Outside a console's folder scope: always. */
|
|
30
|
+
export declare function folderAnswers(path: string, pointer?: boolean): boolean;
|
|
31
|
+
/** `path` is `folder` or inside it (both resolved). */
|
|
32
|
+
export declare function insideFolder(path: string, folder: string): boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the console may read a folder synchronously right now.
|
|
3
|
+
*
|
|
4
|
+
* The console answers every page on one thread, so one synchronous read of a folder that has stopped answering (a
|
|
5
|
+
* drive that hangs, a folder macOS holds in iCloud) freezes the console with it: it listens and answers nothing, not
|
|
6
|
+
* even /healthz (the 0.9.82 and 0.9.87 deploys). So the console's own work (its request handling and its background
|
|
7
|
+
* pass, serve.ts) runs inside a folder scope: there, a synchronous read of a person's folder goes ahead only when that
|
|
8
|
+
* folder answered an off-thread look lately (project-identity.ts `answering`), or it lies in the console's own data.
|
|
9
|
+
* Otherwise the read isn't made and its caller refuses, says the folder is unavailable, or throws FolderUnavailable.
|
|
10
|
+
*
|
|
11
|
+
* Availability never decides authorization: a held-back canonicalization throws FolderUnavailable (project.ts), never
|
|
12
|
+
* answers null, so no caller falls back to a path's spelling because a folder didn't answer.
|
|
13
|
+
*
|
|
14
|
+
* Work outside a console scope (the worker, even in the same process as the console; `up`; the CLI) reads as it
|
|
15
|
+
* always did.
|
|
16
|
+
*/
|
|
17
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
|
+
import { resolve, sep } from "node:path";
|
|
19
|
+
const scope = new AsyncLocalStorage();
|
|
20
|
+
/** What a caller says, and throws, when the gate holds a read back. */
|
|
21
|
+
export const FOLDER_DIDNT_ANSWER = "the folder didn't answer";
|
|
22
|
+
/** A synchronous read the console's folder gate held back: the folder is unavailable now, which is never a "no". */
|
|
23
|
+
export class FolderUnavailable extends Error {
|
|
24
|
+
path;
|
|
25
|
+
constructor(path) {
|
|
26
|
+
super(FOLDER_DIDNT_ANSWER);
|
|
27
|
+
this.path = path;
|
|
28
|
+
this.name = "FolderUnavailable";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Runs `work` (and everything it starts) inside the console's folder scope. */
|
|
32
|
+
export function inFolderScope(gate, work) {
|
|
33
|
+
return scope.run(gate, work);
|
|
34
|
+
}
|
|
35
|
+
/** Whether a synchronous read of `path` may happen now. Outside a console's folder scope: always. */
|
|
36
|
+
export function folderAnswers(path, pointer = false) {
|
|
37
|
+
const gate = scope.getStore();
|
|
38
|
+
return gate === undefined || gate(resolve(path), pointer);
|
|
39
|
+
}
|
|
40
|
+
/** `path` is `folder` or inside it (both resolved). */
|
|
41
|
+
export function insideFolder(path, folder) {
|
|
42
|
+
return path === folder || path.startsWith(folder.endsWith(sep) ? folder : folder + sep);
|
|
43
|
+
}
|
package/dist/project-access.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { canonicalProject } from "./project.js";
|
|
4
|
+
import { FolderUnavailable } from "./folder-gate.js";
|
|
4
5
|
export function normalizeProjectAccess(value) {
|
|
5
6
|
if (value === null)
|
|
6
7
|
return null;
|
|
@@ -26,7 +27,20 @@ export function readProjectAccess(value) {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
export function projectAccessAllows(access, repo) {
|
|
29
|
-
|
|
30
|
+
if (access === null)
|
|
31
|
+
return true;
|
|
32
|
+
if (repo === null)
|
|
33
|
+
return false;
|
|
34
|
+
// A folder the console's gate holds back (folder-gate.ts) is unavailable: access to it is denied, never decided by
|
|
35
|
+
// its spelling.
|
|
36
|
+
try {
|
|
37
|
+
return access.includes(canonicalProject(repo) ?? resolve(repo));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof FolderUnavailable)
|
|
41
|
+
return false;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
30
44
|
}
|
|
31
45
|
/** SQL: projectAccessAllows for an `approver` row (`alias`) and a project path expression, for the dispatch belts. A
|
|
32
46
|
* null list reaches every project; an unreadable one reaches none. */
|
|
@@ -42,9 +42,11 @@ export type Released = {
|
|
|
42
42
|
removed: boolean;
|
|
43
43
|
};
|
|
44
44
|
export declare const PROJECT_ADDED = "project added";
|
|
45
|
+
/** A folder's real path (a folder), null (not one), or "no-answer" (it didn't answer in time). */
|
|
46
|
+
export type FolderResolver = (path: string) => Promise<string | null | "no-answer">;
|
|
45
47
|
export declare const PROJECT_REMOVED = "project removed";
|
|
46
48
|
/** The checkout root a folder belongs to, canonical; never a shell. */
|
|
47
|
-
export declare function proveProjectRoot(path: string, exec?: ProjectExec): Promise<{
|
|
49
|
+
export declare function proveProjectRoot(path: string, exec?: ProjectExec, resolveFolder?: FolderResolver): Promise<{
|
|
48
50
|
ok: true;
|
|
49
51
|
repo: string;
|
|
50
52
|
} | AdmissionFailure>;
|
|
@@ -63,6 +65,9 @@ export declare function admitProject(store: Store, input: {
|
|
|
63
65
|
/** The caller's authority, asked again about the proved repo inside the locked registry update and inside the
|
|
64
66
|
* database transaction (the console: nothing it proved before an await moved, and the repo is still visible). */
|
|
65
67
|
stillAllowed?: (repo: string) => boolean;
|
|
68
|
+
/** How the folder and its checkout root are resolved (the console: off its thread, under a deadline). Absent:
|
|
69
|
+
* canonicalProject. */
|
|
70
|
+
resolveFolder?: FolderResolver;
|
|
66
71
|
}): Promise<Admitted | AdmissionFailure>;
|
|
67
72
|
/**
|
|
68
73
|
* Remove one project from the lists and the builder. Its tasks, results and
|
|
@@ -17,6 +17,7 @@ import { run } from "./exec.js";
|
|
|
17
17
|
import { canonicalProject, projectName, sameRepo } from "./project.js";
|
|
18
18
|
import { remoteUrlProblem } from "./remote-main.js";
|
|
19
19
|
import { addRepos, loadProjectRegistry, removeRepos, updateRepos } from "./repos.js";
|
|
20
|
+
import { FolderUnavailable } from "./folder-gate.js";
|
|
20
21
|
const ADMISSION_GIT = {
|
|
21
22
|
envAllowlist: ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"],
|
|
22
23
|
env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1" },
|
|
@@ -24,15 +25,23 @@ const ADMISSION_GIT = {
|
|
|
24
25
|
export const PROJECT_ADDED = "project added";
|
|
25
26
|
export const PROJECT_REMOVED = "project removed";
|
|
26
27
|
/** The checkout root a folder belongs to, canonical; never a shell. */
|
|
27
|
-
export async function proveProjectRoot(path, exec = run) {
|
|
28
|
+
export async function proveProjectRoot(path, exec = run, resolveFolder) {
|
|
28
29
|
if (path.trim() === "" || path.length > 4096 || /[\x00-\x1f]/.test(path)) {
|
|
29
30
|
return { ok: false, reason: "unavailable", message: "Choose a valid project folder." };
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
// The console resolves both folders off its thread (project-identity.ts resolveNow): its thread never waits on one.
|
|
33
|
+
const canon = async (one) => resolveFolder === undefined ? canonicalProject(one) : resolveFolder(one);
|
|
34
|
+
const didntAnswer = (one) => ({ ok: false, reason: "unavailable", message: `${one} didn't answer. It may be on a drive that's disconnected, or waiting on macOS; try again in a moment.` });
|
|
35
|
+
const canonical = await canon(path);
|
|
36
|
+
if (canonical === "no-answer")
|
|
37
|
+
return didntAnswer(path);
|
|
32
38
|
if (canonical === null)
|
|
33
39
|
return { ok: false, reason: "unavailable", message: `${path} isn't a folder on this computer.` };
|
|
34
40
|
const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000, ...ADMISSION_GIT });
|
|
35
|
-
const
|
|
41
|
+
const topReal = top.code === 0 ? await canon(top.stdout.trim()) : null;
|
|
42
|
+
if (topReal === "no-answer")
|
|
43
|
+
return didntAnswer(top.stdout.trim());
|
|
44
|
+
const repo = topReal;
|
|
36
45
|
if (repo === null)
|
|
37
46
|
return { ok: false, reason: "not-git", message: `${path} isn't a Git repository. Run git init there first, or choose a folder inside one.` };
|
|
38
47
|
return { ok: true, repo };
|
|
@@ -48,7 +57,7 @@ async function originOf(repo, exec) {
|
|
|
48
57
|
}
|
|
49
58
|
/** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
|
|
50
59
|
export async function admitProject(store, input) {
|
|
51
|
-
const proved = await proveProjectRoot(input.path, input.exec);
|
|
60
|
+
const proved = await proveProjectRoot(input.path, input.exec, input.resolveFolder);
|
|
52
61
|
if (!proved.ok)
|
|
53
62
|
return proved;
|
|
54
63
|
const { repo } = proved;
|
|
@@ -110,12 +119,40 @@ export async function admitProject(store, input) {
|
|
|
110
119
|
* saved project changes nothing.
|
|
111
120
|
*/
|
|
112
121
|
export async function releaseProject(store, input) {
|
|
113
|
-
|
|
122
|
+
// Removing only narrows: a folder the console's gate holds back (folder-gate.ts) is removed by its exact saved path,
|
|
123
|
+
// and matches no other spelling.
|
|
124
|
+
// Latched for the whole removal: once its folder was unavailable, a later answer (during an await below) never
|
|
125
|
+
// widens it to other spellings.
|
|
126
|
+
const unavailable = (error) => error instanceof FolderUnavailable;
|
|
127
|
+
let asked, exactOnly = false;
|
|
128
|
+
try {
|
|
129
|
+
asked = canonicalProject(input.path) ?? resolve(input.path);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (!unavailable(error))
|
|
133
|
+
throw error;
|
|
134
|
+
asked = resolve(input.path);
|
|
135
|
+
exactOnly = true;
|
|
136
|
+
}
|
|
137
|
+
const same = (one) => {
|
|
138
|
+
if (exactOnly)
|
|
139
|
+
return false;
|
|
140
|
+
try {
|
|
141
|
+
return sameRepo(one, asked);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (unavailable(error)) {
|
|
145
|
+
exactOnly = true;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
114
151
|
const loaded = input.registryFile === null ? { repos: [] } : await loadProjectRegistry(input.registryFile);
|
|
115
152
|
if ("error" in loaded)
|
|
116
153
|
return { ok: false, reason: "registry", message: loaded.error };
|
|
117
|
-
const entries = loaded.repos.filter(one => one === asked ||
|
|
118
|
-
const rows = store.listProjects().filter(one => one.path === asked ||
|
|
154
|
+
const entries = loaded.repos.filter(one => one === asked || same(one));
|
|
155
|
+
const rows = store.listProjects().filter(one => one.path === asked || same(one.path)).map(one => one.path);
|
|
119
156
|
const repo = entries[0] ?? rows[0] ?? asked;
|
|
120
157
|
if (entries.length === 0 && rows.length === 0)
|
|
121
158
|
return { ok: true, repo, name: projectName(repo), removed: false };
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
|
|
18
18
|
*/
|
|
19
|
+
import { type FolderGate } from "./folder-gate.js";
|
|
19
20
|
import { type FolderLookOptions } from "./folder-look.js";
|
|
20
21
|
export type ProjectIdentities = {
|
|
21
22
|
/** For display and grouping only: the last known identity, else the path as saved (resolved lexically). Reads
|
|
@@ -36,9 +37,16 @@ export type ProjectIdentities = {
|
|
|
36
37
|
/** Moves whenever a known identity changes or goes: a decision made before an await is re-checked against it right
|
|
37
38
|
* before its write. */
|
|
38
39
|
generation: () => number;
|
|
40
|
+
/** Whether a synchronous read inside `path` may happen on the console's thread now (folder-gate.ts): the folder it is
|
|
41
|
+
* in answered a look within the last few minutes, and no look at it since went unanswered. Reads nothing. */
|
|
42
|
+
answering: (path: string) => boolean;
|
|
39
43
|
};
|
|
40
44
|
/** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
|
|
41
45
|
export declare const IDENTITY_LOOKS = 4;
|
|
42
46
|
export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
|
|
43
47
|
/** The synchronous reads, as before: for embedders and tests that pass no identities. */
|
|
44
48
|
export declare function syncProjectIdentities(): ProjectIdentities;
|
|
49
|
+
/** The console's folder gate (folder-gate.ts): its own data folders (the database's, evidence, build checkouts) are read
|
|
50
|
+
* freely when the console names them; a path read from a file a build can write (`pointer`), and anything else, only
|
|
51
|
+
* while `answering` vouches for where it leads. */
|
|
52
|
+
export declare function consoleFolderGate(identities: ProjectIdentities, own: readonly (string | undefined)[]): FolderGate;
|
package/dist/project-identity.js
CHANGED
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
* An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
|
|
18
18
|
*/
|
|
19
19
|
import { resolve } from "node:path";
|
|
20
|
+
import { insideFolder } from "./folder-gate.js";
|
|
20
21
|
import { lookIdentities } from "./folder-look.js";
|
|
21
22
|
import { canonicalProject } from "./project.js";
|
|
22
23
|
/** How long an answer stands before the background pass resolves the path again. */
|
|
23
24
|
const STALE_MS = 60_000;
|
|
25
|
+
/** How long an answer vouches that its folder answers, for `answering`: past it (the background pass every 30 s
|
|
26
|
+
* renews it each minute), a synchronous read waits for the next look. */
|
|
27
|
+
const ANSWERING_MS = 3 * STALE_MS;
|
|
24
28
|
/** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
|
|
25
29
|
export const IDENTITY_LOOKS = 4;
|
|
26
30
|
export function projectIdentities(look = {}, now = Date.now) {
|
|
@@ -137,6 +141,37 @@ export function projectIdentities(look = {}, now = Date.now) {
|
|
|
137
141
|
passes = pass.then(() => undefined, () => undefined);
|
|
138
142
|
return pass;
|
|
139
143
|
},
|
|
144
|
+
answering: path => {
|
|
145
|
+
const when = now();
|
|
146
|
+
const recent = (at) => when - at < ANSWERING_MS;
|
|
147
|
+
// Decided on real paths only. First the spelling: a path under an answered spelling (the longest) is read where
|
|
148
|
+
// that answer says it leads, and only while that answer is recent, since a link can be pointed elsewhere.
|
|
149
|
+
let real = resolve(path);
|
|
150
|
+
let spelled = null;
|
|
151
|
+
for (const [key, answer] of answers) {
|
|
152
|
+
if (answer.real !== null && insideFolder(real, key) && (spelled === null || key.length > spelled.key.length))
|
|
153
|
+
spelled = { key, real: answer.real, at: answer.at };
|
|
154
|
+
}
|
|
155
|
+
if (spelled !== null) {
|
|
156
|
+
if (!recent(spelled.at))
|
|
157
|
+
return false;
|
|
158
|
+
real = resolve(spelled.real, `.${real.slice(spelled.key.length)}`);
|
|
159
|
+
}
|
|
160
|
+
// A folder whose last look didn't answer holds back every read inside where it leads, whatever older answer stands.
|
|
161
|
+
for (const key of suspect)
|
|
162
|
+
if (insideFolder(real, answers.get(key)?.real ?? key))
|
|
163
|
+
return false;
|
|
164
|
+
// Otherwise the nearest real folder an answer names decides, by its newest answer; "/" vouches for nothing below it.
|
|
165
|
+
let nearest = null;
|
|
166
|
+
for (const answer of answers.values()) {
|
|
167
|
+
const folder = answer.real;
|
|
168
|
+
if (folder === null || (folder === "/" && real !== "/") || !insideFolder(real, folder))
|
|
169
|
+
continue;
|
|
170
|
+
if (nearest === null || folder.length > nearest.folder.length || (folder.length === nearest.folder.length && answer.at > nearest.at))
|
|
171
|
+
nearest = { folder, at: answer.at };
|
|
172
|
+
}
|
|
173
|
+
return nearest !== null && recent(nearest.at);
|
|
174
|
+
},
|
|
140
175
|
};
|
|
141
176
|
}
|
|
142
177
|
/** The synchronous reads, as before: for embedders and tests that pass no identities. */
|
|
@@ -149,5 +184,13 @@ export function syncProjectIdentities() {
|
|
|
149
184
|
resolveNow: async (path) => canonicalProject(path),
|
|
150
185
|
generation: () => 0,
|
|
151
186
|
refresh: async () => true,
|
|
187
|
+
answering: () => true,
|
|
152
188
|
};
|
|
153
189
|
}
|
|
190
|
+
/** The console's folder gate (folder-gate.ts): its own data folders (the database's, evidence, build checkouts) are read
|
|
191
|
+
* freely when the console names them; a path read from a file a build can write (`pointer`), and anything else, only
|
|
192
|
+
* while `answering` vouches for where it leads. */
|
|
193
|
+
export function consoleFolderGate(identities, own) {
|
|
194
|
+
const folders = [...new Set(own.filter((one) => typeof one === "string" && one !== "").map(one => resolve(one)))];
|
|
195
|
+
return (path, pointer) => (!pointer && folders.some(folder => insideFolder(path, folder))) || identities.answering(path);
|
|
196
|
+
}
|
package/dist/project.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type ProjectExec = (file: string, args: readonly string[], options?: RunO
|
|
|
16
16
|
* One canonicalization for every path that becomes project identity:
|
|
17
17
|
* startup config, a session open, task placement. Comparisons elsewhere
|
|
18
18
|
* fall back to raw equality for legacy rows written before this existed.
|
|
19
|
+
* Throws FolderUnavailable when the console's folder gate holds the read back (folder-gate.ts).
|
|
19
20
|
*/
|
|
20
21
|
export declare function canonicalProject(path: string): string | null;
|
|
21
22
|
/** Two repo identities, canonical when possible, raw-equal as the legacy fallback. */
|
package/dist/project.js
CHANGED
|
@@ -13,12 +13,18 @@
|
|
|
13
13
|
import { realpathSync, statSync } from "node:fs";
|
|
14
14
|
import { basename, resolve, sep } from "node:path";
|
|
15
15
|
import { run } from "./exec.js";
|
|
16
|
+
import { folderAnswers, FolderUnavailable } from "./folder-gate.js";
|
|
16
17
|
/**
|
|
17
18
|
* One canonicalization for every path that becomes project identity:
|
|
18
19
|
* startup config, a session open, task placement. Comparisons elsewhere
|
|
19
20
|
* fall back to raw equality for legacy rows written before this existed.
|
|
21
|
+
* Throws FolderUnavailable when the console's folder gate holds the read back (folder-gate.ts).
|
|
20
22
|
*/
|
|
21
23
|
export function canonicalProject(path) {
|
|
24
|
+
// In the console, a folder that hasn't answered a look lately is not read on its thread (folder-gate.ts). That is
|
|
25
|
+
// "unavailable", never "not a folder": it throws, so no caller falls back to the path's spelling because of it.
|
|
26
|
+
if (!folderAnswers(path))
|
|
27
|
+
throw new FolderUnavailable(resolve(path));
|
|
22
28
|
try {
|
|
23
29
|
const real = realpathSync(resolve(path));
|
|
24
30
|
return statSync(real).isDirectory() ? real : null;
|
package/dist/proposal.js
CHANGED
|
@@ -30,6 +30,7 @@ export { validateTaskText } from "./task-text.js";
|
|
|
30
30
|
import { resolve } from "node:path";
|
|
31
31
|
import { canonicalProject } from "./project.js";
|
|
32
32
|
import { heuristicSizing, planningSignals, refineFiledSizing } from "./task-sizing.js";
|
|
33
|
+
import { FolderUnavailable } from "./folder-gate.js";
|
|
33
34
|
/** Provenance tokens are part of the audit surface: lowercase, bounded,
|
|
34
35
|
* nothing that could render as anything but itself. */
|
|
35
36
|
const FILED_VIA = /^[a-z][a-z0-9:-]{0,63}$/;
|
|
@@ -75,7 +76,16 @@ function checkRepo(given, admitted) {
|
|
|
75
76
|
// directory resolves through symlinks; a path that does not exist yet
|
|
76
77
|
// still normalizes, because filing must not depend on this machine
|
|
77
78
|
// seeing every repo the plane knows about.
|
|
78
|
-
|
|
79
|
+
let canonical;
|
|
80
|
+
// A folder the console's gate holds back (folder-gate.ts) is unavailable: refused, never filed by its spelling.
|
|
81
|
+
try {
|
|
82
|
+
canonical = canonicalProject(given) ?? resolve(given);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error instanceof FolderUnavailable)
|
|
86
|
+
return refuse("bad-repo", "that repository's folder didn't answer — it may be on a drive that's disconnected; try again in a moment");
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
79
89
|
if (admitted !== undefined && !admitted.includes(canonical)) {
|
|
80
90
|
return refuse("outside-ceiling", "that repository is outside what this surface was configured to show");
|
|
81
91
|
}
|
package/dist/recipes.js
CHANGED
|
@@ -12,6 +12,7 @@ import { applyModeToNewFiling } from "./plan-auto.js";
|
|
|
12
12
|
import { canonicalProject } from "./project.js";
|
|
13
13
|
import { RECIPE_FORMAT, RECIPE_LIMITS, readRecipeDocument } from "./contracts/recipes.js";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
|
+
import { FolderUnavailable } from "./folder-gate.js";
|
|
15
16
|
export const RECIPE_SCHEMA = `
|
|
16
17
|
CREATE TABLE IF NOT EXISTS workflow_recipe (
|
|
17
18
|
id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision > 0),
|
|
@@ -47,7 +48,17 @@ export class RecipeError extends Error {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
const honest = (text) => !hasForbiddenControls(text) && !hasDisguisedText(text);
|
|
50
|
-
const recipeProject = (repo) =>
|
|
51
|
+
const recipeProject = (repo) => {
|
|
52
|
+
// A project folder the console's gate holds back (folder-gate.ts) is unavailable, never matched by its spelling.
|
|
53
|
+
try {
|
|
54
|
+
return canonicalProject(repo) ?? resolve(repo);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof FolderUnavailable)
|
|
58
|
+
throw new RecipeError("This project's folder didn't answer. Try again in a moment.", 503);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
51
62
|
/** What a refusal at each top-level field tells the person, after the path-named line. */
|
|
52
63
|
const FIELD_ADVICE = {
|
|
53
64
|
name: "Name, goal, exclusions, and allowed paths must be readable text.", description: "Name, goal, exclusions, and allowed paths must be readable text.",
|
package/dist/serve.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { secretFolder } from "./tool-secret-store.js";
|
|
2
2
|
import { acceptedIdentities, recoverLearning } from "./project-learning.js";
|
|
3
|
-
import { syncProjectIdentities } from "./project-identity.js";
|
|
3
|
+
import { consoleFolderGate, syncProjectIdentities } from "./project-identity.js";
|
|
4
|
+
import { inFolderScope } from "./folder-gate.js";
|
|
4
5
|
import { firstTasksOffThread, recoverLearningOffThread } from "./learning-look.js";
|
|
5
6
|
import { loadOrCreateVapidKeys } from "./push.js";
|
|
6
7
|
import { checkoutPlan } from "./checkout-cleanup.js";
|
|
@@ -670,7 +671,7 @@ export function createDecisionServer(options) {
|
|
|
670
671
|
instrumentRequest(store.telemetry, request, response);
|
|
671
672
|
// Every password checked while answering counts against where this request came from (sign-in-guard.ts).
|
|
672
673
|
// A GET or HEAD only reads (docs/plans/get-side-effects.md): store reads that would tidy up as they go don't.
|
|
673
|
-
const answer = () => handle(request, response);
|
|
674
|
+
const answer = () => consoleFolders(options, identities, () => handle(request, response));
|
|
674
675
|
void withPasswordSource(joinSourceOf(request), () => request.method === "GET" || request.method === "HEAD" ? asReadOnlyRequest(answer) : answer()).catch(error => {
|
|
675
676
|
// Every unhandled error is logged (v99): the path and the message, never the request's body or query. The reference
|
|
676
677
|
// is what a person's error page shows, so the log line can be found from it.
|
|
@@ -1986,7 +1987,9 @@ export function createDecisionServer(options) {
|
|
|
1986
1987
|
// deterministic scan is idle without new meaningful work; shutdown drains it.
|
|
1987
1988
|
let leadMaintenance = null;
|
|
1988
1989
|
let leadClosing = false;
|
|
1989
|
-
|
|
1990
|
+
// Started in the console's folder scope (folder-gate.ts), so their own timers and the personal queue's kick (which
|
|
1991
|
+
// restores the context it was started in, never a request's) read folders as the console's work does.
|
|
1992
|
+
server.once('listening', () => consoleFolders(options, identities, () => {
|
|
1990
1993
|
team.start();
|
|
1991
1994
|
personalQueue.start();
|
|
1992
1995
|
leadMaintenance = startMaintenance({ intervalMs: 5_000, shouldStop: () => leadClosing,
|
|
@@ -1996,7 +1999,7 @@ export function createDecisionServer(options) {
|
|
|
1996
1999
|
// Pending delivery is durable and is read again on the next pass; a failure is said where follow status is read.
|
|
1997
2000
|
onError: error => recordLeadFollowFailure(store, error, clock()),
|
|
1998
2001
|
});
|
|
1999
|
-
});
|
|
2002
|
+
}));
|
|
2000
2003
|
// Background checks (docs/plans/get-side-effects.md): what pages show about this computer's agent sign-ins and the
|
|
2001
2004
|
// services Toolroll uses is asked here, on a timer, and a page reads the last answer. A page visit never starts a
|
|
2002
2005
|
// process or a network call, and never acts on what a check finds: lifting a sign-in pause and the lead-by-default
|
|
@@ -2011,7 +2014,7 @@ export function createDecisionServer(options) {
|
|
|
2011
2014
|
}
|
|
2012
2015
|
catch { /* a page says secrets can't be read; the next action reports why */ }
|
|
2013
2016
|
backgroundChecks = startMaintenance({ intervalMs: options.backgroundChecksMs ?? BACKGROUND_CHECKS_MS, shouldStop: () => leadClosing,
|
|
2014
|
-
run: async () => {
|
|
2017
|
+
run: () => consoleFolders(options, identities, async () => {
|
|
2015
2018
|
const now = Date.now();
|
|
2016
2019
|
const waits = [];
|
|
2017
2020
|
if (now - agentsCheckedAt >= (options.backgroundChecksMs ?? LOCAL_AGENTS_EVERY_MS)) {
|
|
@@ -2109,7 +2112,7 @@ export function createDecisionServer(options) {
|
|
|
2109
2112
|
catch { /* shown as last saved */ }
|
|
2110
2113
|
}
|
|
2111
2114
|
await Promise.all(waits);
|
|
2112
|
-
},
|
|
2115
|
+
}),
|
|
2113
2116
|
onError: () => { }, });
|
|
2114
2117
|
void backgroundChecks.runNow();
|
|
2115
2118
|
});
|
|
@@ -2415,3 +2418,22 @@ function startupCeiling(repos, roots, identities, canon) {
|
|
|
2415
2418
|
configuredPaths,
|
|
2416
2419
|
};
|
|
2417
2420
|
}
|
|
2421
|
+
/** Each console's folder gate (folder-gate.ts), made once for its options. */
|
|
2422
|
+
const consoleGates = new WeakMap();
|
|
2423
|
+
/**
|
|
2424
|
+
* Runs the console's own work (a request, a background pass) in its folder scope: with identities resolved off its
|
|
2425
|
+
* thread (up and serve), it reads a person's folder synchronously only while that folder answered a look lately; its
|
|
2426
|
+
* own data (the database's folder, evidence, build checkouts) is read freely. Without identities (embedders, tests):
|
|
2427
|
+
* as before. Work that isn't the console's (a worker in the same process) never runs in it.
|
|
2428
|
+
*/
|
|
2429
|
+
function consoleFolders(options, identities, work) {
|
|
2430
|
+
if (options.identities === undefined)
|
|
2431
|
+
return work();
|
|
2432
|
+
let gate = consoleGates.get(options);
|
|
2433
|
+
if (gate === undefined) {
|
|
2434
|
+
const databaseFile = options.store.databaseFile();
|
|
2435
|
+
gate = consoleFolderGate(identities, [options.configDir, options.evidenceRoot, options.poolRoot, databaseFile === null ? undefined : dirname(databaseFile)]);
|
|
2436
|
+
consoleGates.set(options, gate);
|
|
2437
|
+
}
|
|
2438
|
+
return inFolderScope(gate, work);
|
|
2439
|
+
}
|
package/dist/server/pages.js
CHANGED
|
@@ -703,7 +703,7 @@ export function createPagesHandlers(runtime) {
|
|
|
703
703
|
// connects its builder; there is no separate restart or binding step.
|
|
704
704
|
// The admission asks again, inside its registry lock and its transaction, about the repo it proves.
|
|
705
705
|
const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
|
|
706
|
-
const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed });
|
|
706
|
+
const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
|
|
707
707
|
if (!admitted.ok) {
|
|
708
708
|
return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, admitted.reason === "moved" ? 409 : 400, safeReturn(body.get("return")), "open");
|
|
709
709
|
}
|
|
@@ -891,7 +891,7 @@ export function createPagesHandlers(runtime) {
|
|
|
891
891
|
return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400, "/", "clone");
|
|
892
892
|
}
|
|
893
893
|
const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
|
|
894
|
-
const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed });
|
|
894
|
+
const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
|
|
895
895
|
if (!joined.ok) {
|
|
896
896
|
return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, joined.reason === "moved" ? 409 : 400, "/", "clone");
|
|
897
897
|
}
|
|
@@ -1083,8 +1083,10 @@ export function decisionAnswerScript() {
|
|
|
1083
1083
|
`var f=e.target;if(!f||!f.classList||!f.classList.contains("decide-inline"))return;` +
|
|
1084
1084
|
`e.preventDefault();` +
|
|
1085
1085
|
`var card=f.closest("[data-decision-id]");` +
|
|
1086
|
-
|
|
1087
|
-
`
|
|
1086
|
+
// The action attribute, not f.action: a field named "action" would shadow that property (DOM clobbering).
|
|
1087
|
+
`var to=new URL(HTMLFormElement.prototype.getAttribute.call(f,"action")||"",location.href).href;` +
|
|
1088
|
+
`var page=to.replace(/\\/answer$/,"");` +
|
|
1089
|
+
`fetch(to,{method:"POST",credentials:"same-origin",` +
|
|
1088
1090
|
`headers:{"content-type":"application/x-www-form-urlencoded"},` +
|
|
1089
1091
|
`body:new URLSearchParams(new FormData(f)).toString()})` +
|
|
1090
1092
|
`.then(function(r){return r.text().then(function(t){return{r:r,t:t}})})` +
|
package/dist/setup-guide.js
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { validModelId } from "./provider.js";
|
|
8
8
|
import { planInstall, applyInstall, skillContent, SKILL_DIR, SKILL_FILE } from "./skills.js";
|
|
9
|
+
import { folderAnswers } from "./folder-gate.js";
|
|
9
10
|
export const ASSISTANTS = withAcp({
|
|
10
11
|
claude: { name: "Claude", description: "Use Claude Code for your tasks." },
|
|
11
12
|
codex: { name: "Codex", description: "Use your OpenAI coding assistant." },
|
|
@@ -37,6 +38,9 @@ export function modelChoices(provider, configured, home = homedir()) {
|
|
|
37
38
|
return choices;
|
|
38
39
|
}
|
|
39
40
|
export function detectPreparation(repo) {
|
|
41
|
+
// In the console, a folder that hasn't answered a look lately is not read on its thread (folder-gate.ts).
|
|
42
|
+
if (!folderAnswers(repo))
|
|
43
|
+
return null;
|
|
40
44
|
const has = (file) => existsSync(join(repo, file));
|
|
41
45
|
if (has("package.json")) {
|
|
42
46
|
if (has("pnpm-lock.yaml"))
|
|
@@ -59,6 +63,8 @@ export function detectPreparation(repo) {
|
|
|
59
63
|
}
|
|
60
64
|
/** Browser installation may write only this known file inside its project. */
|
|
61
65
|
export function previewProjectInstructions(repo) {
|
|
66
|
+
if (!folderAnswers(repo))
|
|
67
|
+
return { ok: false, message: "This project folder didn't answer. Try again in a moment." };
|
|
62
68
|
try {
|
|
63
69
|
if (!lstatSync(repo).isDirectory())
|
|
64
70
|
return { ok: false, message: "This project folder is unavailable. Reconnect it before adding instructions." };
|