toolroll 0.9.82 → 0.9.84
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 +41 -0
- package/README.md +4 -3
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -22
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +7 -0
- package/dist/contracts/console-api.d.ts +2 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +110 -0
- package/dist/folder-look.js +306 -0
- package/dist/guides.js +4 -3
- package/dist/learning-child.d.ts +1 -0
- package/dist/learning-child.js +34 -0
- package/dist/learning-look.d.ts +25 -0
- package/dist/learning-look.js +72 -0
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +53 -0
- package/dist/operate.js +277 -49
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-identity.d.ts +44 -0
- package/dist/project-identity.js +153 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -1
- package/dist/project-tools.js +7 -2
- package/dist/project.d.ts +4 -1
- package/dist/project.js +11 -4
- package/dist/repos.d.ts +7 -0
- package/dist/repos.js +57 -0
- package/dist/serve.js +91 -17
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +5 -2
- package/dist/server/flows.js +13 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +9 -0
- package/dist/server/pages.js +102 -92
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-pages.d.ts +12 -16
- package/dist/server/render-pages.js +30 -69
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.js +7 -14
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +8 -1
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +211 -71
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/tasks.js +19 -5
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project identities as the console uses them, without touching a person's folders on its thread.
|
|
3
|
+
*
|
|
4
|
+
* A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
|
|
5
|
+
* stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread, and
|
|
6
|
+
* never waits for one before it answers:
|
|
7
|
+
*
|
|
8
|
+
* - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
|
|
9
|
+
* - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
|
|
10
|
+
* refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
|
|
11
|
+
* - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
|
|
12
|
+
* Anything else is refused this time and resolved by the next background pass, never resolved inline.
|
|
13
|
+
* - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
|
|
14
|
+
* - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
|
|
15
|
+
* and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
|
|
16
|
+
*
|
|
17
|
+
* An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
|
|
18
|
+
*/
|
|
19
|
+
import { type FolderLookOptions } from "./folder-look.js";
|
|
20
|
+
export type ProjectIdentities = {
|
|
21
|
+
/** For display and grouping only: the last known identity, else the path as saved (resolved lexically). Reads
|
|
22
|
+
* nothing. Never an authorization answer: use `strict`. */
|
|
23
|
+
hot: (path: string) => string;
|
|
24
|
+
/** For authorization: the known real folder path, else null (unknown or not a folder: fail closed until resolved).
|
|
25
|
+
* Reads nothing; an unknown path is asked about on the next refresh. */
|
|
26
|
+
strict: (path: string) => string | null;
|
|
27
|
+
/** The known identity: a real folder path, null (not a folder), or undefined (never answered). Reads nothing. */
|
|
28
|
+
known: (path: string) => string | null | undefined;
|
|
29
|
+
/** A request's path: its known identity, or the path itself when it is exactly one of `saved` (projects the plane
|
|
30
|
+
* itself proved and admitted); else undefined. */
|
|
31
|
+
vouched: (path: string, saved: () => Iterable<string>) => string | undefined;
|
|
32
|
+
/** This path's identity now, off the thread: a real folder path, null, or "no-answer" past the deadline. */
|
|
33
|
+
resolveNow: (path: string) => Promise<string | null | "no-answer">;
|
|
34
|
+
/** Resolve `paths`, everything asked about since, and answers older than a minute. False when some didn't answer. */
|
|
35
|
+
refresh: (paths: Iterable<string>) => Promise<boolean>;
|
|
36
|
+
/** Moves whenever a known identity changes or goes: a decision made before an await is re-checked against it right
|
|
37
|
+
* before its write. */
|
|
38
|
+
generation: () => number;
|
|
39
|
+
};
|
|
40
|
+
/** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
|
|
41
|
+
export declare const IDENTITY_LOOKS = 4;
|
|
42
|
+
export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
|
|
43
|
+
/** The synchronous reads, as before: for embedders and tests that pass no identities. */
|
|
44
|
+
export declare function syncProjectIdentities(): ProjectIdentities;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project identities as the console uses them, without touching a person's folders on its thread.
|
|
3
|
+
*
|
|
4
|
+
* A project's identity is its real path (project.ts canonicalProject). Resolving one reads the folder, and a folder can
|
|
5
|
+
* stop answering (folder-look.ts), so the console never resolves one on a page or at startup on its own thread, and
|
|
6
|
+
* never waits for one before it answers:
|
|
7
|
+
*
|
|
8
|
+
* - `hot` (display and grouping only): the last answer, else the path as saved. Nothing is started by asking.
|
|
9
|
+
* - `strict` (visibility and authorization): only a resolved identity; an unknown one fails closed until the next
|
|
10
|
+
* refresh resolves it, so a stored path that leads outside its root never passes on its spelling.
|
|
11
|
+
* - `vouched` (a path a request names): only a path whose identity is known, or one that is exactly a saved identity.
|
|
12
|
+
* Anything else is refused this time and resolved by the next background pass, never resolved inline.
|
|
13
|
+
* - `resolveNow` (an action that needs a path checked now, a POST): resolved off the thread under the look's deadline.
|
|
14
|
+
* - `refresh` (the console's background pass, the first started right after it listens): resolves what was asked about
|
|
15
|
+
* and what has gone stale. The console answers before any of it does; until a path answers it fails closed.
|
|
16
|
+
*
|
|
17
|
+
* An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
|
|
18
|
+
*/
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import { lookIdentities } from "./folder-look.js";
|
|
21
|
+
import { canonicalProject } from "./project.js";
|
|
22
|
+
/** How long an answer stands before the background pass resolves the path again. */
|
|
23
|
+
const STALE_MS = 60_000;
|
|
24
|
+
/** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
|
|
25
|
+
export const IDENTITY_LOOKS = 4;
|
|
26
|
+
export function projectIdentities(look = {}, now = Date.now) {
|
|
27
|
+
/** Each answer with when the look that answered it started (folder-look.ts `started`, kept across joined looks): an
|
|
28
|
+
* answer from a look started earlier never overwrites one from a look started later. */
|
|
29
|
+
const answers = new Map();
|
|
30
|
+
const wanted = new Set();
|
|
31
|
+
/** Paths whose last look didn't answer: looked at only on their own (joining a look still under way), never in a
|
|
32
|
+
* batch, so one folder that never answers holds one child, not one per differently made-up batch. */
|
|
33
|
+
const suspect = new Set();
|
|
34
|
+
let generation = 0;
|
|
35
|
+
const known = (path) => answers.get(resolve(path))?.real;
|
|
36
|
+
const put = (key, real, at, seq) => {
|
|
37
|
+
const before = answers.get(key);
|
|
38
|
+
// A known identity that changes (or goes) moves authority: anything decided on the old one is out of date.
|
|
39
|
+
if (before !== undefined && before.real !== real)
|
|
40
|
+
generation++;
|
|
41
|
+
answers.set(key, { real, at, seq });
|
|
42
|
+
};
|
|
43
|
+
const store = (found, seq) => {
|
|
44
|
+
const at = now();
|
|
45
|
+
for (const [path, real] of found) {
|
|
46
|
+
const key = resolve(path);
|
|
47
|
+
// Superseded: a later lookup already answered for this path. Its real path's entry goes with it.
|
|
48
|
+
if ((answers.get(key)?.seq ?? -1) > seq)
|
|
49
|
+
continue;
|
|
50
|
+
put(key, real, at, seq);
|
|
51
|
+
wanted.delete(key);
|
|
52
|
+
// A real path is its own identity.
|
|
53
|
+
if (real !== null && (answers.get(real)?.seq ?? -1) <= seq)
|
|
54
|
+
put(real, real, at, seq);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const resolveNow = async (path) => {
|
|
58
|
+
const key = resolve(path);
|
|
59
|
+
const looked = await lookIdentities([key], look);
|
|
60
|
+
if (!looked.ok) {
|
|
61
|
+
suspect.add(key);
|
|
62
|
+
return "no-answer";
|
|
63
|
+
}
|
|
64
|
+
suspect.delete(key);
|
|
65
|
+
// No start known: older than every answer, so it only fills what nothing answered yet.
|
|
66
|
+
store(looked.value, looked.started ?? 0);
|
|
67
|
+
return answers.get(key)?.real ?? null;
|
|
68
|
+
};
|
|
69
|
+
const refreshNow = async (paths) => {
|
|
70
|
+
const at = now();
|
|
71
|
+
const due = new Set(wanted);
|
|
72
|
+
for (const one of paths) {
|
|
73
|
+
const key = resolve(one);
|
|
74
|
+
const answer = answers.get(key);
|
|
75
|
+
if (answer === undefined || at - answer.at >= STALE_MS)
|
|
76
|
+
due.add(key);
|
|
77
|
+
}
|
|
78
|
+
for (const [key, answer] of answers)
|
|
79
|
+
if (at - answer.at >= STALE_MS)
|
|
80
|
+
due.add(key);
|
|
81
|
+
if (due.size === 0)
|
|
82
|
+
return true;
|
|
83
|
+
const alone = [...due].filter(one => suspect.has(one));
|
|
84
|
+
const batched = [...due].filter(one => !suspect.has(one));
|
|
85
|
+
if (batched.length > 0) {
|
|
86
|
+
const batch = await lookIdentities(batched, look);
|
|
87
|
+
if (batch.ok)
|
|
88
|
+
store(batch.value, batch.started ?? 0);
|
|
89
|
+
else
|
|
90
|
+
alone.push(...batched);
|
|
91
|
+
}
|
|
92
|
+
// One folder that doesn't answer mustn't hold the others: each on its own, a few at a time (inside the looks'
|
|
93
|
+
// child cap, leaving room for a person's actions), each under its deadline, each stored as soon as it answers.
|
|
94
|
+
let missed = false;
|
|
95
|
+
const worker = async () => {
|
|
96
|
+
for (let one = alone.shift(); one !== undefined; one = alone.shift())
|
|
97
|
+
if (await resolveNow(one) === "no-answer")
|
|
98
|
+
missed = true;
|
|
99
|
+
};
|
|
100
|
+
await Promise.all(Array.from({ length: Math.min(IDENTITY_LOOKS, alone.length) }, worker));
|
|
101
|
+
return !missed;
|
|
102
|
+
};
|
|
103
|
+
let passes = Promise.resolve();
|
|
104
|
+
return {
|
|
105
|
+
hot: path => {
|
|
106
|
+
const key = resolve(path);
|
|
107
|
+
const answer = answers.get(key);
|
|
108
|
+
if (answer === undefined)
|
|
109
|
+
wanted.add(key);
|
|
110
|
+
return answer?.real ?? key;
|
|
111
|
+
},
|
|
112
|
+
known,
|
|
113
|
+
strict: path => {
|
|
114
|
+
const key = resolve(path);
|
|
115
|
+
const answer = answers.get(key);
|
|
116
|
+
if (answer === undefined)
|
|
117
|
+
wanted.add(key);
|
|
118
|
+
return answer?.real ?? null;
|
|
119
|
+
},
|
|
120
|
+
vouched: (path, saved) => {
|
|
121
|
+
const key = resolve(path);
|
|
122
|
+
const answer = answers.get(key);
|
|
123
|
+
if (answer !== undefined && answer.real !== null)
|
|
124
|
+
return answer.real;
|
|
125
|
+
for (const one of saved())
|
|
126
|
+
if (one === key)
|
|
127
|
+
return key;
|
|
128
|
+
wanted.add(key);
|
|
129
|
+
return undefined;
|
|
130
|
+
},
|
|
131
|
+
resolveNow,
|
|
132
|
+
generation: () => generation,
|
|
133
|
+
refresh: paths => {
|
|
134
|
+
// One pass at a time: a pass asked for while another runs waits for it, then resolves only what is still due.
|
|
135
|
+
const asked = [...paths];
|
|
136
|
+
const pass = passes.then(() => refreshNow(asked));
|
|
137
|
+
passes = pass.then(() => undefined, () => undefined);
|
|
138
|
+
return pass;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** The synchronous reads, as before: for embedders and tests that pass no identities. */
|
|
143
|
+
export function syncProjectIdentities() {
|
|
144
|
+
return {
|
|
145
|
+
hot: path => canonicalProject(path) ?? path,
|
|
146
|
+
known: path => canonicalProject(path),
|
|
147
|
+
strict: path => canonicalProject(path),
|
|
148
|
+
vouched: path => canonicalProject(path) ?? undefined,
|
|
149
|
+
resolveNow: async (path) => canonicalProject(path),
|
|
150
|
+
generation: () => 0,
|
|
151
|
+
refresh: async () => true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -23,19 +23,17 @@ export declare function knowledgeVersion(store: Store, repo: string, actor: stri
|
|
|
23
23
|
/** Chat reads only an admitted project. Keep the full source in the project UI. */
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
25
|
title?: never;
|
|
26
|
-
content?: never;
|
|
27
|
-
history?: never;
|
|
28
26
|
revision: number;
|
|
29
27
|
stale: boolean;
|
|
30
28
|
notice: string;
|
|
31
29
|
decision?: never;
|
|
30
|
+
content?: never;
|
|
31
|
+
history?: never;
|
|
32
32
|
instructions?: never;
|
|
33
33
|
references?: never;
|
|
34
34
|
decisions?: never;
|
|
35
35
|
} | {
|
|
36
36
|
title?: never;
|
|
37
|
-
content?: never;
|
|
38
|
-
history?: never;
|
|
39
37
|
stale?: never;
|
|
40
38
|
decision: {
|
|
41
39
|
repo: string;
|
|
@@ -52,26 +50,28 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
52
50
|
id: number;
|
|
53
51
|
};
|
|
54
52
|
notice: string;
|
|
53
|
+
content?: never;
|
|
55
54
|
revision?: never;
|
|
55
|
+
history?: never;
|
|
56
56
|
instructions?: never;
|
|
57
57
|
references?: never;
|
|
58
58
|
decisions?: never;
|
|
59
59
|
} | {
|
|
60
|
-
history?: never;
|
|
61
60
|
stale?: never;
|
|
62
61
|
decision?: never;
|
|
63
62
|
revision: number;
|
|
64
63
|
title: string;
|
|
65
64
|
content: string;
|
|
66
65
|
notice: string;
|
|
66
|
+
history?: never;
|
|
67
67
|
instructions?: never;
|
|
68
68
|
references?: never;
|
|
69
69
|
decisions?: never;
|
|
70
70
|
} | {
|
|
71
71
|
title?: never;
|
|
72
|
-
content?: never;
|
|
73
72
|
stale?: never;
|
|
74
73
|
decision?: never;
|
|
74
|
+
content?: never;
|
|
75
75
|
revision: number;
|
|
76
76
|
history: {
|
|
77
77
|
revision: number;
|
package/dist/project-tools.d.ts
CHANGED
|
@@ -103,7 +103,7 @@ export type FoundTool = {
|
|
|
103
103
|
* Codex's (from `codex mcp list --json`, passed in). The first of each
|
|
104
104
|
* name wins; unusable entries are left out.
|
|
105
105
|
*/
|
|
106
|
-
export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv): FoundTool[];
|
|
106
|
+
export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv, projectConfig?: Record<string, unknown> | null): FoundTool[];
|
|
107
107
|
/** Start the server once, ask for its tools, and stop it: the proof a build can use it. */
|
|
108
108
|
export declare function testTool(spec: ToolSpec, values: Record<string, string>, options: {
|
|
109
109
|
timeoutMs?: number;
|
|
@@ -313,3 +313,6 @@ export declare function toolServerFor(store: ToolServerStore, repo: string, opti
|
|
|
313
313
|
home?: string;
|
|
314
314
|
available?: () => boolean;
|
|
315
315
|
}): ToolServer;
|
|
316
|
+
/** Each project's `.mcp.json` as the console last read it off its thread (its background pass): what its pages and the
|
|
317
|
+
* lead offer, so neither reads a project folder on the console's thread. */
|
|
318
|
+
export declare const savedProjectMcp: Map<string, Record<string, unknown> | null>;
|
package/dist/project-tools.js
CHANGED
|
@@ -496,7 +496,7 @@ function fromCodexEntry(raw, environment) {
|
|
|
496
496
|
* Codex's (from `codex mcp list --json`, passed in). The first of each
|
|
497
497
|
* name wins; unusable entries are left out.
|
|
498
498
|
*/
|
|
499
|
-
export function discoverTools(repo, codexServers, home = homedir(), environment = process.env) {
|
|
499
|
+
export function discoverTools(repo, codexServers, home = homedir(), environment = process.env, projectConfig) {
|
|
500
500
|
const found = [];
|
|
501
501
|
const add = (one) => { if (one !== null && !found.some(other => other.spec.name === one.spec.name))
|
|
502
502
|
found.push(one); };
|
|
@@ -509,7 +509,9 @@ export function discoverTools(repo, codexServers, home = homedir(), environment
|
|
|
509
509
|
return null;
|
|
510
510
|
}
|
|
511
511
|
};
|
|
512
|
-
|
|
512
|
+
// The project's own file as given (the console reads it off its thread: folder-look.ts), else as last read there
|
|
513
|
+
// (savedProjectMcp), else read here (the CLI and the worker).
|
|
514
|
+
const project = projectConfig !== undefined ? projectConfig : savedProjectMcp.has(repo) ? savedProjectMcp.get(repo) ?? null : readJson(join(repo, ".mcp.json"));
|
|
513
515
|
for (const [name, raw] of Object.entries((project?.["mcpServers"] ?? {})))
|
|
514
516
|
add(fromClaudeEntry(name, raw, "this project's .mcp.json"));
|
|
515
517
|
const claude = readJson(join(home, ".claude.json"));
|
|
@@ -1149,3 +1151,6 @@ export function toolServerFor(store, repo, options = {}) {
|
|
|
1149
1151
|
const untrusted = existsSync(cache) ? [realpathSync(cache)] : [];
|
|
1150
1152
|
return { fence: fenceFor("tool-server", { databaseFile, worktree: null, home, boundary: null, binding: null, runFolders: runFolders(null), poolRoots, extra: untrusted }) };
|
|
1151
1153
|
}
|
|
1154
|
+
/** Each project's `.mcp.json` as the console last read it off its thread (its background pass): what its pages and the
|
|
1155
|
+
* lead offer, so neither reads a project folder on the console's thread. */
|
|
1156
|
+
export const savedProjectMcp = new Map();
|
package/dist/project.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ export type ProjectCeiling = {
|
|
|
29
29
|
repos: readonly string[];
|
|
30
30
|
/** Canonical directories: any git repo under one is authorized. */
|
|
31
31
|
roots: readonly string[];
|
|
32
|
+
/** How this ceiling resolves a path it is asked about (project-identity.ts): the console's saved identities, which
|
|
33
|
+
* read no folder. Absent: canonicalProject, a synchronous read. */
|
|
34
|
+
canon?: (path: string) => string | null;
|
|
32
35
|
};
|
|
33
36
|
/**
|
|
34
37
|
* Resolve the startup configuration once. A configured path that does not
|
|
@@ -36,7 +39,7 @@ export type ProjectCeiling = {
|
|
|
36
39
|
* dropping would silently *widen* the server toward unscoped mode, and a
|
|
37
40
|
* ceiling that loosens itself when a disk is unmounted is not a ceiling.
|
|
38
41
|
*/
|
|
39
|
-
export declare function resolveCeiling(repos: readonly string[], roots: readonly string[]): {
|
|
42
|
+
export declare function resolveCeiling(repos: readonly string[], roots: readonly string[], canon?: (path: string) => string | null | undefined): {
|
|
40
43
|
ceiling: ProjectCeiling;
|
|
41
44
|
unresolved: string[];
|
|
42
45
|
};
|
package/dist/project.js
CHANGED
|
@@ -67,10 +67,10 @@ export async function projectSelection(paths, exec = run) {
|
|
|
67
67
|
* dropping would silently *widen* the server toward unscoped mode, and a
|
|
68
68
|
* ceiling that loosens itself when a disk is unmounted is not a ceiling.
|
|
69
69
|
*/
|
|
70
|
-
export function resolveCeiling(repos, roots) {
|
|
70
|
+
export function resolveCeiling(repos, roots, canon = canonicalProject) {
|
|
71
71
|
const unresolved = [];
|
|
72
72
|
const keep = (paths) => [...new Set(paths.map(path => {
|
|
73
|
-
const canonical =
|
|
73
|
+
const canonical = canon(path) ?? null;
|
|
74
74
|
if (canonical !== null)
|
|
75
75
|
return canonical;
|
|
76
76
|
unresolved.push(path);
|
|
@@ -90,7 +90,7 @@ export function unscoped(ceiling) {
|
|
|
90
90
|
export async function authorizedProject(ceiling, path, exec = run) {
|
|
91
91
|
if (unscoped(ceiling))
|
|
92
92
|
return true;
|
|
93
|
-
const canonical = canonicalProject(path);
|
|
93
|
+
const canonical = (ceiling.canon ?? canonicalProject)(path);
|
|
94
94
|
if (canonical === null)
|
|
95
95
|
return false;
|
|
96
96
|
if (ceiling.repos.includes(canonical))
|
|
@@ -114,8 +114,15 @@ export function rowVisible(ceiling, repo) {
|
|
|
114
114
|
return true;
|
|
115
115
|
// Exact saved identities are common. Check the whole set before resolving
|
|
116
116
|
// aliases, so the hundredth project does not stat the other99 directories.
|
|
117
|
-
|
|
117
|
+
const canon = ceiling.canon;
|
|
118
|
+
if (ceiling.repos.includes(repo) || ceiling.repos.some(one => canon === undefined ? sameRepo(one, repo) : (canon(one) ?? null) !== null && canon(one) === canon(repo)))
|
|
118
119
|
return true;
|
|
120
|
+
// With a resolver (the console's saved identities), an identity it can't vouch for fails closed before any root is
|
|
121
|
+
// matched: a path's spelling never stands in for where it leads.
|
|
122
|
+
if (canon !== undefined) {
|
|
123
|
+
const resolved = canon(repo);
|
|
124
|
+
return resolved !== null && ceiling.roots.some(root => resolved.startsWith(root + sep));
|
|
125
|
+
}
|
|
119
126
|
const canonical = canonicalProject(repo) ?? repo;
|
|
120
127
|
return ceiling.roots.some(root => canonical.startsWith(root + sep));
|
|
121
128
|
}
|
package/dist/repos.d.ts
CHANGED
|
@@ -57,4 +57,11 @@ export declare function updateProjectRegistry(file: string, transform: (registry
|
|
|
57
57
|
} | RegistryUpdateFailure>;
|
|
58
58
|
export declare function addRepos(existing: readonly string[], incoming: readonly string[]): string[];
|
|
59
59
|
export declare function removeRepos(existing: readonly string[], targets: readonly string[]): string[];
|
|
60
|
+
/** Beside the saved list: how many times each line has been removed from it (written under its lock). */
|
|
61
|
+
export declare function registryRemovalsFile(file: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
|
|
64
|
+
* as one answers null: its caller treats every line as changed (fail closed).
|
|
65
|
+
*/
|
|
66
|
+
export declare function registryRemovals(file: string): Record<string, number> | null;
|
|
60
67
|
export {};
|
package/dist/repos.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* to edit it by hand or check it into their dotfiles — which also means it is
|
|
11
11
|
* untrusted input and gets validated like any other.
|
|
12
12
|
*/
|
|
13
|
+
import { closeSync as closeFd, constants as fsConstants, fstatSync, openSync as openFd, readSync, renameSync as renameFile, writeFileSync } from "node:fs";
|
|
13
14
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
15
|
import { dirname } from "node:path";
|
|
15
16
|
import { configBase, namedPath } from "./names.js";
|
|
@@ -213,6 +214,11 @@ export async function updateProjectRegistry(file, transform) {
|
|
|
213
214
|
held = false; // abandon: no unlink, no publication
|
|
214
215
|
return { ok: false, reason: "abandoned", message: "the registry lock aged out before the update could publish — nothing changed; try again" };
|
|
215
216
|
}
|
|
217
|
+
// Each line this update removes counts one more removal, written BEFORE the list is published: whoever holds a
|
|
218
|
+
// start's authority for a saved line (upDeferredRetry) sees that it was removed, even if it was saved again since.
|
|
219
|
+
const gone = current.repos.filter(one => !next.repos.includes(one));
|
|
220
|
+
if (gone.length > 0)
|
|
221
|
+
bumpRegistryRemovals(file, gone, `${process.pid}.${token.slice(0, 8)}`);
|
|
216
222
|
const temp = `${file}.${process.pid}.${token.slice(0, 8)}.tmp`;
|
|
217
223
|
const fd = openSync(temp, "wx");
|
|
218
224
|
writeSync(fd, `${JSON.stringify({ version: CONFIG_VERSION, ...next }, null, 2)}
|
|
@@ -267,3 +273,54 @@ function readProjectRegistry(parsed) {
|
|
|
267
273
|
function sortUnique(repos) {
|
|
268
274
|
return [...new Set(repos)].sort();
|
|
269
275
|
}
|
|
276
|
+
/** Beside the saved list: how many times each line has been removed from it (written under its lock). */
|
|
277
|
+
export function registryRemovalsFile(file) {
|
|
278
|
+
return `${file}.removals.json`;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* How many times each saved line has been removed, as recorded under the saved list's lock. A file that can't be read
|
|
282
|
+
* as one answers null: its caller treats every line as changed (fail closed).
|
|
283
|
+
*/
|
|
284
|
+
export function registryRemovals(file) {
|
|
285
|
+
let fd;
|
|
286
|
+
try {
|
|
287
|
+
fd = openFd(registryRemovalsFile(file), fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
return error.code === "ENOENT" ? {} : null;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const held = fstatSync(fd);
|
|
294
|
+
if (!held.isFile() || held.size > 4 * 1024 * 1024)
|
|
295
|
+
return null;
|
|
296
|
+
const buffer = Buffer.alloc(held.size);
|
|
297
|
+
const read = held.size === 0 ? 0 : readSync(fd, buffer, 0, buffer.length, 0);
|
|
298
|
+
const parsed = JSON.parse(buffer.toString("utf8", 0, read));
|
|
299
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
300
|
+
return null;
|
|
301
|
+
const out = {};
|
|
302
|
+
for (const [line, count] of Object.entries(parsed)) {
|
|
303
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
304
|
+
return null;
|
|
305
|
+
out[line] = count;
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
closeFd(fd);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** One more removal for each of `lines`, written whole and renamed into place. Called only under the list's lock. */
|
|
317
|
+
function bumpRegistryRemovals(file, lines, tag) {
|
|
318
|
+
const kept = registryRemovals(file);
|
|
319
|
+
// An unreadable record starts again from the clock, so no count read before can come round again.
|
|
320
|
+
const counts = kept ?? {};
|
|
321
|
+
for (const line of lines)
|
|
322
|
+
counts[line] = kept === null ? Date.now() : (counts[line] ?? 0) + 1;
|
|
323
|
+
const temp = `${registryRemovalsFile(file)}.${tag}.tmp`;
|
|
324
|
+
writeFileSync(temp, `${JSON.stringify(counts)}\n`, { flag: "wx", mode: 0o600 });
|
|
325
|
+
renameFile(temp, registryRemovalsFile(file));
|
|
326
|
+
}
|