toolroll 0.9.82 → 0.9.83
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 +14 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +107 -0
- package/dist/folder-look.js +302 -0
- 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/operate.d.ts +32 -0
- package/dist/operate.js +186 -28
- package/dist/project-identity.d.ts +37 -0
- package/dist/project-identity.js +106 -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 +35 -8
- package/dist/server/flows.js +9 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +11 -0
- package/dist/server/http.js +3 -0
- package/dist/server/pages.js +62 -88
- package/dist/server/render-pages.d.ts +2 -16
- package/dist/server/render-pages.js +1 -55
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.js +14 -3
- package/dist/server/tasks.js +7 -3
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.83 — 2026-10-31
|
|
4
|
+
|
|
5
|
+
- **A folder that doesn't answer can't freeze the console.** A disconnected
|
|
6
|
+
drive, an iCloud-synced Documents folder or a pending macOS privacy prompt
|
|
7
|
+
used to stall the whole console at startup. Folder reads now run in a
|
|
8
|
+
separate, time-limited process: the console keeps answering, and the one
|
|
9
|
+
page that needed the folder says it didn't answer.
|
|
10
|
+
- **Projects come back by themselves.** A saved project whose folder was
|
|
11
|
+
away when Toolroll started connects as soon as the folder returns, with no
|
|
12
|
+
restart. It reconnects only to the same folder it was saved as, and a
|
|
13
|
+
project you remove stays removed.
|
|
14
|
+
- **Safer project paths.** A project's access is decided from its real,
|
|
15
|
+
resolved location, never from how its path is spelled.
|
|
16
|
+
|
|
3
17
|
## 0.9.82 — 2026-10-31
|
|
4
18
|
|
|
5
19
|
- **A failing nightly holds your releases.** Turn it on with
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -97,6 +97,7 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
|
|
|
97
97
|
export declare function lockHeld(file: string): boolean;
|
|
98
98
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
99
99
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
100
|
+
version?: never;
|
|
100
101
|
active: boolean;
|
|
101
102
|
phase: string;
|
|
102
103
|
detail: string;
|
|
@@ -110,7 +111,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
110
111
|
wasRunning?: never;
|
|
111
112
|
backupPath?: never;
|
|
112
113
|
updateId?: never;
|
|
113
|
-
version?: never;
|
|
114
114
|
buildId?: never;
|
|
115
115
|
workDir?: never;
|
|
116
116
|
} | {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Started only by learning-look.ts: one project's first-task suggestions (first-run.ts findFirstTasks), in a process
|
|
3
|
+
* of its own so the hardened git's synchronous reads of the project's folder never hold the console's thread. Its one
|
|
4
|
+
* argument is JSON naming the project; it prints the suggestions as JSON and ends.
|
|
5
|
+
*/
|
|
6
|
+
import { findFirstTasks } from "./first-run.js";
|
|
7
|
+
const input = JSON.parse(process.argv[2] ?? "null");
|
|
8
|
+
if (input === null || typeof input.repo !== "string" || input.repo === "" || input.repo.includes("\0"))
|
|
9
|
+
process.exit(2);
|
|
10
|
+
const found = await findFirstTasks(input.repo);
|
|
11
|
+
process.stdout.write(JSON.stringify(found), () => process.exit(0));
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads of a person's own folders, kept off the console's thread.
|
|
3
|
+
*
|
|
4
|
+
* A folder can stop answering: a drive that disconnected without unmounting, a folder macOS keeps in iCloud (Desktop &
|
|
5
|
+
* Documents), or one behind a privacy prompt nobody has answered. Opening it then blocks until the system answers, and
|
|
6
|
+
* a synchronous read on the console's thread freezes every page with it (the 0.9.82 deploy, 2026-10-31: the console
|
|
7
|
+
* listened for twenty minutes without answering, its main thread inside a directory read). So every such read runs in a
|
|
8
|
+
* child process, the same read it always was, and the console waits for it only as long as a deadline allows: a folder
|
|
9
|
+
* that didn't answer degrades the one thing that needed it, in plain words, and the next look tries again.
|
|
10
|
+
*
|
|
11
|
+
* A look never goes into another mounted drive, /Volumes, or the protected folders under ~/Library, unless that is the
|
|
12
|
+
* folder the person chose. Its answers are filesystem data, read exactly as before (directory names, a repository's
|
|
13
|
+
* `.git/config` as a plain bounded file never followed through a link), and the caller still checks every path against
|
|
14
|
+
* its own ceiling.
|
|
15
|
+
*/
|
|
16
|
+
/** How long the console waits for a look before saying the folder didn't answer. */
|
|
17
|
+
export declare const FOLDER_LOOK_MS = 4000;
|
|
18
|
+
/** The `url` of `[remote "origin"]` in a git config's text. Plain JavaScript with no outside names: the look's child
|
|
19
|
+
* runs this same function (its source is sent along), so the parent and the child read a config one way. */
|
|
20
|
+
export declare function originUrlIn(config: string): string | null;
|
|
21
|
+
export type FolderLookRequest =
|
|
22
|
+
/** One folder's sub-folders, as the browse page shows them: at most 400 kept of 2,000 entries read, links resolved
|
|
23
|
+
* and kept only inside a root. */
|
|
24
|
+
{
|
|
25
|
+
op: "list";
|
|
26
|
+
path: string;
|
|
27
|
+
roots: readonly string[];
|
|
28
|
+
home: string;
|
|
29
|
+
}
|
|
30
|
+
/** The origin of each registered project, and of each repository directly in a root (at most 400 folders a root). */
|
|
31
|
+
| {
|
|
32
|
+
op: "origins";
|
|
33
|
+
registered: readonly string[];
|
|
34
|
+
roots: readonly string[];
|
|
35
|
+
home: string;
|
|
36
|
+
}
|
|
37
|
+
/** Where each path really is (its real path, when that is a folder): a project's identity, resolved off the thread. */
|
|
38
|
+
| {
|
|
39
|
+
op: "identities";
|
|
40
|
+
paths: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
/** A project's own `.mcp.json`, read as a plain bounded file and parsed (null when absent or not a JSON object). */
|
|
43
|
+
| {
|
|
44
|
+
op: "projectMcp";
|
|
45
|
+
path: string;
|
|
46
|
+
};
|
|
47
|
+
/** One look under way: its answer, and when its process has ended (a stuck child can outlive its answer). */
|
|
48
|
+
export type FolderLookRun = {
|
|
49
|
+
answer: Promise<unknown>;
|
|
50
|
+
ended: Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
/** Starts one look. Production starts a child process; tests pass their own. */
|
|
53
|
+
export type FolderLookRunner = (request: FolderLookRequest, deadlineMs: number) => FolderLookRun;
|
|
54
|
+
export type FolderLook<T> = {
|
|
55
|
+
ok: true;
|
|
56
|
+
value: T;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
why: "no-answer" | "failed";
|
|
60
|
+
};
|
|
61
|
+
/** At most this many look processes at once, stuck ones included: a folder that never answers costs one each. */
|
|
62
|
+
export declare const FOLDER_LOOK_CHILDREN = 8;
|
|
63
|
+
/** The production look: the script above in a child process of this same Node (or Electron as Node), killed at its
|
|
64
|
+
* deadline. Its environment carries nothing of this process's (no NODE_OPTIONS, no secrets). At the deadline its pipe
|
|
65
|
+
* is closed and nothing of it keeps this process alive; `ended` settles only when the process has really gone. */
|
|
66
|
+
export declare const childLook: FolderLookRunner;
|
|
67
|
+
/** How many look processes are running now (stuck ones included). */
|
|
68
|
+
export declare function folderLooksRunning(): number;
|
|
69
|
+
/**
|
|
70
|
+
* One look, answered within `deadlineMs` whatever the folder does: its value, or that the folder didn't answer (it ran
|
|
71
|
+
* past the deadline, the same look is still stuck, or every child slot is held) or the look failed. A look already
|
|
72
|
+
* under way for the same request is joined, never started twice.
|
|
73
|
+
*/
|
|
74
|
+
export declare function lookFolders(request: FolderLookRequest, options?: {
|
|
75
|
+
runner?: FolderLookRunner;
|
|
76
|
+
deadlineMs?: number;
|
|
77
|
+
}): Promise<FolderLook<unknown>>;
|
|
78
|
+
export type FolderEntry = {
|
|
79
|
+
name: string;
|
|
80
|
+
path: string;
|
|
81
|
+
git: boolean;
|
|
82
|
+
};
|
|
83
|
+
export type FolderLookOptions = {
|
|
84
|
+
runner?: FolderLookRunner;
|
|
85
|
+
deadlineMs?: number;
|
|
86
|
+
home?: string;
|
|
87
|
+
};
|
|
88
|
+
/** A folder's sub-folders for the browse page (null `at`: outside every root, or not a folder). */
|
|
89
|
+
export declare function listFolder(path: string, roots: readonly string[], options?: FolderLookOptions): Promise<FolderLook<{
|
|
90
|
+
at: string | null;
|
|
91
|
+
entries: FolderEntry[];
|
|
92
|
+
}>>;
|
|
93
|
+
/** The origin URL of each registered project, and the repositories directly in each root with theirs. */
|
|
94
|
+
export declare function localOrigins(registered: readonly string[], roots: readonly string[], options?: FolderLookOptions): Promise<FolderLook<{
|
|
95
|
+
registered: {
|
|
96
|
+
path: string;
|
|
97
|
+
url: string | null;
|
|
98
|
+
}[];
|
|
99
|
+
found: {
|
|
100
|
+
path: string;
|
|
101
|
+
url: string | null;
|
|
102
|
+
}[];
|
|
103
|
+
}>>;
|
|
104
|
+
/** Each path's identity (its real path when that is a folder, else null), resolved off the thread. */
|
|
105
|
+
export declare function lookIdentities(paths: readonly string[], options?: FolderLookOptions): Promise<FolderLook<Map<string, string | null>>>;
|
|
106
|
+
/** A project's own `.mcp.json` (parsed object, or null when absent or not one), read off the thread. */
|
|
107
|
+
export declare function lookProjectMcp(repo: string, options?: FolderLookOptions): Promise<FolderLook<Record<string, unknown> | null>>;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads of a person's own folders, kept off the console's thread.
|
|
3
|
+
*
|
|
4
|
+
* A folder can stop answering: a drive that disconnected without unmounting, a folder macOS keeps in iCloud (Desktop &
|
|
5
|
+
* Documents), or one behind a privacy prompt nobody has answered. Opening it then blocks until the system answers, and
|
|
6
|
+
* a synchronous read on the console's thread freezes every page with it (the 0.9.82 deploy, 2026-10-31: the console
|
|
7
|
+
* listened for twenty minutes without answering, its main thread inside a directory read). So every such read runs in a
|
|
8
|
+
* child process, the same read it always was, and the console waits for it only as long as a deadline allows: a folder
|
|
9
|
+
* that didn't answer degrades the one thing that needed it, in plain words, and the next look tries again.
|
|
10
|
+
*
|
|
11
|
+
* A look never goes into another mounted drive, /Volumes, or the protected folders under ~/Library, unless that is the
|
|
12
|
+
* folder the person chose. Its answers are filesystem data, read exactly as before (directory names, a repository's
|
|
13
|
+
* `.git/config` as a plain bounded file never followed through a link), and the caller still checks every path against
|
|
14
|
+
* its own ceiling.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
/** How long the console waits for a look before saying the folder didn't answer. */
|
|
20
|
+
export const FOLDER_LOOK_MS = 4_000;
|
|
21
|
+
/** The `url` of `[remote "origin"]` in a git config's text. Plain JavaScript with no outside names: the look's child
|
|
22
|
+
* runs this same function (its source is sent along), so the parent and the child read a config one way. */
|
|
23
|
+
export function originUrlIn(config) {
|
|
24
|
+
let inOrigin = false;
|
|
25
|
+
for (const line of config.split("\n")) {
|
|
26
|
+
if (/^\s*\[/.test(line)) {
|
|
27
|
+
inOrigin = /^\s*\[remote "origin"\]\s*$/.test(line);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!inOrigin)
|
|
31
|
+
continue;
|
|
32
|
+
const url = /^\s*url\s*=\s*(.+)$/.exec(line)?.[1];
|
|
33
|
+
if (url !== undefined)
|
|
34
|
+
return url.trim();
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** At most this many look processes at once, stuck ones included: a folder that never answers costs one each. */
|
|
39
|
+
export const FOLDER_LOOK_CHILDREN = 8;
|
|
40
|
+
/**
|
|
41
|
+
* The child's script: plain CommonJS, given the request as its one argument, printing one JSON answer. Every file it
|
|
42
|
+
* reads is opened without following a link and without waiting on a FIFO, checked to be a regular file under its cap
|
|
43
|
+
* on the open descriptor, and read only up to its limit; every folder is read through a bounded iterator.
|
|
44
|
+
*/
|
|
45
|
+
const LOOK_SCRIPT = `"use strict";
|
|
46
|
+
const fs = require("fs"), path = require("path");
|
|
47
|
+
const originUrlIn = ${originUrlIn.toString()};
|
|
48
|
+
const request = JSON.parse(process.argv[1]);
|
|
49
|
+
const within = (p, root) => p === root || p.startsWith(root === "/" ? "/" : root + "/");
|
|
50
|
+
const home = request.home || "/nonexistent";
|
|
51
|
+
const fenced = [path.join(home, "Library"), "/Volumes", "/System/Volumes", "/Network", "/net", "/private/var/automount"];
|
|
52
|
+
// A place the look never enters, unless the path is inside a folder the person chose there (that root itself, not any
|
|
53
|
+
// root that happens to lie in the same protected tree).
|
|
54
|
+
const offLimits = (p, roots) => fenced.some(one => within(p, one) && !roots.some(root => within(root, one) && within(p, root)));
|
|
55
|
+
const rootOf = (p, roots) => roots.filter(root => within(p, root)).sort((a, b) => b.length - a.length)[0];
|
|
56
|
+
// A plain file read bounded by construction (ghlist review, finding 1): never through a link (O_NOFOLLOW), never
|
|
57
|
+
// waiting on a FIFO (O_NONBLOCK), a regular file under the cap by the opened descriptor, at most the limit read.
|
|
58
|
+
function readBounded(file, cap, limit) {
|
|
59
|
+
let fd = null;
|
|
60
|
+
try {
|
|
61
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
62
|
+
const stat = fs.fstatSync(fd);
|
|
63
|
+
if (!stat.isFile() || stat.size > cap) return null;
|
|
64
|
+
const buffer = Buffer.alloc(Math.min(limit, stat.size));
|
|
65
|
+
const read = buffer.length === 0 ? 0 : fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
66
|
+
return buffer.toString("utf8", 0, read);
|
|
67
|
+
} catch { return null; }
|
|
68
|
+
finally { if (fd !== null) { try { fs.closeSync(fd); } catch {} } }
|
|
69
|
+
}
|
|
70
|
+
function origin(repo) {
|
|
71
|
+
const config = readBounded(path.join(repo, ".git", "config"), 1024 * 1024, 64 * 1024);
|
|
72
|
+
return config === null ? null : originUrlIn(config);
|
|
73
|
+
}
|
|
74
|
+
function children(folder, roots, links) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const folderDev = fs.statSync(folder).dev;
|
|
77
|
+
const dir = fs.opendirSync(folder);
|
|
78
|
+
try {
|
|
79
|
+
for (let seen = 0; seen < 2000 && out.length < 400; seen++) {
|
|
80
|
+
const entry = dir.readSync();
|
|
81
|
+
if (entry === null) break;
|
|
82
|
+
if (!(entry.isDirectory() || (links && entry.isSymbolicLink()))) continue;
|
|
83
|
+
const spelled = path.join(folder, entry.name);
|
|
84
|
+
if (offLimits(spelled, roots)) continue;
|
|
85
|
+
let real, stat;
|
|
86
|
+
try { real = fs.realpathSync(spelled); } catch { continue; }
|
|
87
|
+
if (offLimits(real, roots) || !roots.some(root => within(real, root))) continue;
|
|
88
|
+
try { stat = fs.statSync(real); } catch { continue; }
|
|
89
|
+
if (!stat.isDirectory()) continue;
|
|
90
|
+
// Another drive mounted here: not entered unless it is itself a chosen folder.
|
|
91
|
+
if (stat.dev !== folderDev && !roots.includes(real)) continue;
|
|
92
|
+
out.push({ name: entry.name, path: real });
|
|
93
|
+
}
|
|
94
|
+
} finally { dir.closeSync(); }
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function look() {
|
|
98
|
+
if (request.op === "projectMcp") {
|
|
99
|
+
const text = readBounded(path.join(request.path, ".mcp.json"), 1024 * 1024, 1024 * 1024);
|
|
100
|
+
if (text === null) return { config: null };
|
|
101
|
+
try { const parsed = JSON.parse(text); return { config: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null }; }
|
|
102
|
+
catch { return { config: null }; }
|
|
103
|
+
}
|
|
104
|
+
if (request.op === "identities") {
|
|
105
|
+
return { paths: request.paths.slice(0, 500).map(one => {
|
|
106
|
+
try { const real = fs.realpathSync(path.resolve(one)); return { path: one, real: fs.statSync(real).isDirectory() ? real : null }; }
|
|
107
|
+
catch { return { path: one, real: null }; }
|
|
108
|
+
}) };
|
|
109
|
+
}
|
|
110
|
+
const roots = request.roots;
|
|
111
|
+
if (request.op === "list") {
|
|
112
|
+
const at = fs.realpathSync(request.path);
|
|
113
|
+
const root = rootOf(at, roots);
|
|
114
|
+
if (root === undefined || offLimits(at, roots) || !fs.statSync(at).isDirectory()) return { at: null, entries: [] };
|
|
115
|
+
// A folder on another drive than the root that allows it is not entered (the root itself may be one).
|
|
116
|
+
if (at !== root && fs.statSync(at).dev !== fs.statSync(root).dev) return { at: null, entries: [] };
|
|
117
|
+
const entries = children(at, roots, true)
|
|
118
|
+
.filter(one => !one.name.startsWith(".") && one.name !== "node_modules")
|
|
119
|
+
.map(one => ({ name: one.name, path: one.path, git: fs.existsSync(path.join(one.path, ".git")) }));
|
|
120
|
+
return { at, entries };
|
|
121
|
+
}
|
|
122
|
+
const registered = request.registered.slice(0, 500).map(repo => ({ path: repo, url: origin(repo) }));
|
|
123
|
+
const found = [];
|
|
124
|
+
for (const root of roots) {
|
|
125
|
+
let listed = [];
|
|
126
|
+
try { listed = children(root, roots, false); } catch { continue; }
|
|
127
|
+
for (const child of listed) {
|
|
128
|
+
if (!fs.existsSync(path.join(child.path, ".git"))) continue;
|
|
129
|
+
found.push({ path: child.path, url: origin(child.path) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { registered, found };
|
|
133
|
+
}
|
|
134
|
+
let answer;
|
|
135
|
+
try { answer = { ok: true, value: look() }; } catch (error) { answer = { ok: false, code: error && error.code || null }; }
|
|
136
|
+
process.stdout.write(JSON.stringify(answer));
|
|
137
|
+
`;
|
|
138
|
+
/** The production look: the script above in a child process of this same Node (or Electron as Node), killed at its
|
|
139
|
+
* deadline. Its environment carries nothing of this process's (no NODE_OPTIONS, no secrets). At the deadline its pipe
|
|
140
|
+
* is closed and nothing of it keeps this process alive; `ended` settles only when the process has really gone. */
|
|
141
|
+
export const childLook = (request, deadlineMs) => {
|
|
142
|
+
const child = spawn(process.execPath, ["-e", LOOK_SCRIPT, JSON.stringify(request)], {
|
|
143
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
144
|
+
env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin", ELECTRON_RUN_AS_NODE: "1" },
|
|
145
|
+
killSignal: "SIGKILL",
|
|
146
|
+
});
|
|
147
|
+
child.unref();
|
|
148
|
+
const ended = new Promise(resolveEnded => {
|
|
149
|
+
child.once("exit", () => resolveEnded());
|
|
150
|
+
child.once("error", () => resolveEnded());
|
|
151
|
+
});
|
|
152
|
+
const answer = new Promise((resolveAnswer, reject) => {
|
|
153
|
+
const chunks = [];
|
|
154
|
+
let size = 0;
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
child.kill("SIGKILL");
|
|
157
|
+
// A child stuck in the kernel can't be reaped yet: its pipe is closed now, so it holds nothing here.
|
|
158
|
+
child.stdout.destroy();
|
|
159
|
+
reject(new Error("the look didn't answer"));
|
|
160
|
+
}, deadlineMs);
|
|
161
|
+
timer.unref();
|
|
162
|
+
child.stdout.on("data", (chunk) => {
|
|
163
|
+
size += chunk.length;
|
|
164
|
+
if (size > 8 * 1024 * 1024) {
|
|
165
|
+
child.kill("SIGKILL");
|
|
166
|
+
child.stdout.destroy();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
chunks.push(chunk);
|
|
170
|
+
});
|
|
171
|
+
child.once("error", error => { clearTimeout(timer); reject(error); });
|
|
172
|
+
child.once("close", code => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
if (code !== 0) {
|
|
175
|
+
reject(new Error(`the look ended with ${code}`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
resolveAnswer(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
reject(error);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
answer.catch(() => { });
|
|
187
|
+
return { answer, ended };
|
|
188
|
+
};
|
|
189
|
+
/** Looks whose process hasn't ended, by request: callers asking the same thing share one; each holds a child slot
|
|
190
|
+
* until its process is gone. */
|
|
191
|
+
const inFlight = new Map();
|
|
192
|
+
/** The same request however its paths were spelled: resolved paths, roots in order. */
|
|
193
|
+
function lookKey(request) {
|
|
194
|
+
const sorted = (paths) => paths.map(one => resolve(one)).sort();
|
|
195
|
+
switch (request.op) {
|
|
196
|
+
case "list": return JSON.stringify([request.op, resolve(request.path), sorted(request.roots), request.home]);
|
|
197
|
+
case "origins": return JSON.stringify([request.op, sorted(request.registered), sorted(request.roots), request.home]);
|
|
198
|
+
case "identities": return JSON.stringify([request.op, sorted(request.paths)]);
|
|
199
|
+
case "projectMcp": return JSON.stringify([request.op, resolve(request.path)]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** How many look processes are running now (stuck ones included). */
|
|
203
|
+
export function folderLooksRunning() {
|
|
204
|
+
return inFlight.size;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* One look, answered within `deadlineMs` whatever the folder does: its value, or that the folder didn't answer (it ran
|
|
208
|
+
* past the deadline, the same look is still stuck, or every child slot is held) or the look failed. A look already
|
|
209
|
+
* under way for the same request is joined, never started twice.
|
|
210
|
+
*/
|
|
211
|
+
export async function lookFolders(request, options = {}) {
|
|
212
|
+
const deadlineMs = options.deadlineMs ?? FOLDER_LOOK_MS;
|
|
213
|
+
const key = lookKey(request);
|
|
214
|
+
let run = inFlight.get(key);
|
|
215
|
+
if (run === undefined) {
|
|
216
|
+
if (inFlight.size >= FOLDER_LOOK_CHILDREN)
|
|
217
|
+
return { ok: false, why: "no-answer" };
|
|
218
|
+
try {
|
|
219
|
+
run = (options.runner ?? childLook)(request, deadlineMs);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return { ok: false, why: "failed" };
|
|
223
|
+
}
|
|
224
|
+
const mine = run;
|
|
225
|
+
inFlight.set(key, mine);
|
|
226
|
+
const forget = () => { if (inFlight.get(key) === mine)
|
|
227
|
+
inFlight.delete(key); };
|
|
228
|
+
mine.ended.then(forget, forget);
|
|
229
|
+
}
|
|
230
|
+
let timer;
|
|
231
|
+
const late = new Promise(resolveLate => { timer = setTimeout(() => resolveLate("late"), deadlineMs); });
|
|
232
|
+
try {
|
|
233
|
+
const first = await Promise.race([run.answer.then(value => ({ value }), () => "failed"), late]);
|
|
234
|
+
if (first === "late")
|
|
235
|
+
return { ok: false, why: "no-answer" };
|
|
236
|
+
if (first === "failed")
|
|
237
|
+
return { ok: false, why: "failed" };
|
|
238
|
+
const answer = first.value;
|
|
239
|
+
if (answer === null || typeof answer !== "object" || answer.ok !== true)
|
|
240
|
+
return { ok: false, why: "failed" };
|
|
241
|
+
return { ok: true, value: answer.value };
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const text = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.includes("\0");
|
|
248
|
+
/** A folder's sub-folders for the browse page (null `at`: outside every root, or not a folder). */
|
|
249
|
+
export async function listFolder(path, roots, options = {}) {
|
|
250
|
+
const looked = await lookFolders({ op: "list", path, roots: [...roots], home: options.home ?? homedir() }, options);
|
|
251
|
+
if (!looked.ok)
|
|
252
|
+
return looked;
|
|
253
|
+
const value = looked.value;
|
|
254
|
+
if (value === null || typeof value !== "object" || !(value.at === null || text(value.at)) || !Array.isArray(value.entries))
|
|
255
|
+
return { ok: false, why: "failed" };
|
|
256
|
+
const entries = value.entries.flatMap((one) => {
|
|
257
|
+
const row = one;
|
|
258
|
+
return row !== null && typeof row === "object" && text(row.name) && text(row.path) && typeof row.git === "boolean" ? [{ name: row.name, path: row.path, git: row.git }] : [];
|
|
259
|
+
});
|
|
260
|
+
return { ok: true, value: { at: value.at, entries } };
|
|
261
|
+
}
|
|
262
|
+
/** The origin URL of each registered project, and the repositories directly in each root with theirs. */
|
|
263
|
+
export async function localOrigins(registered, roots, options = {}) {
|
|
264
|
+
const looked = await lookFolders({ op: "origins", registered: [...registered], roots: [...roots], home: options.home ?? homedir() }, options);
|
|
265
|
+
if (!looked.ok)
|
|
266
|
+
return looked;
|
|
267
|
+
const value = looked.value;
|
|
268
|
+
if (value === null || typeof value !== "object" || !Array.isArray(value.registered) || !Array.isArray(value.found))
|
|
269
|
+
return { ok: false, why: "failed" };
|
|
270
|
+
const rows = (list) => list.flatMap((one) => {
|
|
271
|
+
const row = one;
|
|
272
|
+
return row !== null && typeof row === "object" && text(row.path) && (row.url === null || text(row.url)) ? [{ path: row.path, url: row.url }] : [];
|
|
273
|
+
});
|
|
274
|
+
return { ok: true, value: { registered: rows(value.registered), found: rows(value.found) } };
|
|
275
|
+
}
|
|
276
|
+
/** Each path's identity (its real path when that is a folder, else null), resolved off the thread. */
|
|
277
|
+
export async function lookIdentities(paths, options = {}) {
|
|
278
|
+
const looked = await lookFolders({ op: "identities", paths: [...paths] }, options);
|
|
279
|
+
if (!looked.ok)
|
|
280
|
+
return looked;
|
|
281
|
+
const value = looked.value;
|
|
282
|
+
if (value === null || typeof value !== "object" || !Array.isArray(value.paths))
|
|
283
|
+
return { ok: false, why: "failed" };
|
|
284
|
+
const out = new Map();
|
|
285
|
+
for (const one of value.paths) {
|
|
286
|
+
const row = one;
|
|
287
|
+
if (row !== null && typeof row === "object" && text(row.path) && (row.real === null || text(row.real)))
|
|
288
|
+
out.set(row.path, row.real);
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, value: out };
|
|
291
|
+
}
|
|
292
|
+
/** A project's own `.mcp.json` (parsed object, or null when absent or not one), read off the thread. */
|
|
293
|
+
export async function lookProjectMcp(repo, options = {}) {
|
|
294
|
+
const looked = await lookFolders({ op: "projectMcp", path: repo }, options);
|
|
295
|
+
if (!looked.ok)
|
|
296
|
+
return looked;
|
|
297
|
+
const value = looked.value;
|
|
298
|
+
if (value === null || typeof value !== "object")
|
|
299
|
+
return { ok: false, why: "failed" };
|
|
300
|
+
const config = value.config;
|
|
301
|
+
return { ok: true, value: config !== null && typeof config === "object" && !Array.isArray(config) ? config : null };
|
|
302
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Started only by learning-look.ts: one project's learning recovery (project-learning.ts acceptedIdentities and
|
|
3
|
+
* recoverLearning) in a process of its own, so its synchronous reads of the project's Git folder can never hold the
|
|
4
|
+
* console's thread. It opens the database without migrating, does that one project, and ends; the console kills it
|
|
5
|
+
* at its deadline. Its one argument is JSON naming the database, the evidence folder and the project.
|
|
6
|
+
*/
|
|
7
|
+
import { acceptedIdentities, recoverLearning } from "./project-learning.js";
|
|
8
|
+
import { openStoreNoMigrate } from "./store.js";
|
|
9
|
+
const input = JSON.parse(process.argv[2] ?? "null");
|
|
10
|
+
const text = (value) => typeof value === "string" && value !== "" && !value.includes("\0");
|
|
11
|
+
if (input === null || !text(input.databaseFile) || !text(input.evidenceRoot) || !text(input.repo))
|
|
12
|
+
process.exit(2);
|
|
13
|
+
const opened = openStoreNoMigrate(input.databaseFile);
|
|
14
|
+
if (!opened.ok)
|
|
15
|
+
process.exit(3);
|
|
16
|
+
let code = 0;
|
|
17
|
+
try {
|
|
18
|
+
try {
|
|
19
|
+
acceptedIdentities(opened.store, input.repo);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
code = 1;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
recoverLearning(opened.store, input.evidenceRoot, input.repo);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
code = 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
opened.store.close();
|
|
33
|
+
}
|
|
34
|
+
process.exit(code);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FirstTaskSuggestion } from "./first-run.js";
|
|
2
|
+
/** How long one project's recovery may take before it is stopped (the next pass tries again). */
|
|
3
|
+
export declare const LEARNING_LOOK_MS = 60000;
|
|
4
|
+
/** How long reading one project's first-task suggestions may take. */
|
|
5
|
+
export declare const FIRST_TASKS_LOOK_MS = 20000;
|
|
6
|
+
export type LearningOutcome = "done" | "failed" | "no-answer";
|
|
7
|
+
export type LearningRunner = (input: {
|
|
8
|
+
databaseFile: string;
|
|
9
|
+
evidenceRoot: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
}, deadlineMs: number) => Promise<LearningOutcome>;
|
|
12
|
+
/** The production learning runner: learning-child in its own process. */
|
|
13
|
+
export declare const learningChild: LearningRunner;
|
|
14
|
+
/** Recover learning for each project in turn, each in its own bounded process. */
|
|
15
|
+
export declare function recoverLearningOffThread(input: {
|
|
16
|
+
databaseFile: string;
|
|
17
|
+
evidenceRoot: string;
|
|
18
|
+
repos: readonly string[];
|
|
19
|
+
}, options?: {
|
|
20
|
+
runner?: LearningRunner;
|
|
21
|
+
deadlineMs?: number;
|
|
22
|
+
}): Promise<Map<string, LearningOutcome>>;
|
|
23
|
+
/** One project's first-task suggestions (first-run.ts findFirstTasks: its issues through gh, its TODOs through the
|
|
24
|
+
* hardened git) read in a process of its own. Rejects when it failed or didn't answer: the caller keeps the generic ones. */
|
|
25
|
+
export declare function firstTasksOffThread(repo: string, deadlineMs?: number): Promise<FirstTaskSuggestion[]>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work that reads a project's own folder synchronously, run in a process of its own and killed at a deadline, so a
|
|
3
|
+
* folder that doesn't answer (a disconnected drive, an iCloud folder macOS is holding) costs that one piece of work,
|
|
4
|
+
* never the console's thread: learning recovery (learning-child.ts) and Chat's first-task suggestions
|
|
5
|
+
* (first-tasks-child.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {} from "./first-run.js";
|
|
10
|
+
/** How long one project's recovery may take before it is stopped (the next pass tries again). */
|
|
11
|
+
export const LEARNING_LOOK_MS = 60_000;
|
|
12
|
+
/** How long reading one project's first-task suggestions may take. */
|
|
13
|
+
export const FIRST_TASKS_LOOK_MS = 20_000;
|
|
14
|
+
/** Only what git and gh need from the environment (no NODE_OPTIONS, no other secrets). */
|
|
15
|
+
const CHILD_ENV = ["PATH", "HOME", "TMPDIR", "LANG", "USER", "XDG_CONFIG_HOME", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST", "GH_CONFIG_DIR"];
|
|
16
|
+
/** One of this build's own child entries, given `input` as JSON, killed at the deadline. Its stdout, bounded. */
|
|
17
|
+
function ownChild(name, input, deadlineMs) {
|
|
18
|
+
return new Promise(resolveOutcome => {
|
|
19
|
+
const source = import.meta.url.endsWith(".ts");
|
|
20
|
+
const entry = fileURLToPath(new URL(source ? `./${name}.ts` : `./${name}.js`, import.meta.url));
|
|
21
|
+
const env = { ELECTRON_RUN_AS_NODE: "1" };
|
|
22
|
+
for (const one of CHILD_ENV)
|
|
23
|
+
if (process.env[one] !== undefined)
|
|
24
|
+
env[one] = process.env[one];
|
|
25
|
+
const child = spawn(process.execPath, [...(source ? ["--import", import.meta.resolve("tsx")] : []), entry, JSON.stringify(input)], {
|
|
26
|
+
stdio: ["ignore", "pipe", "ignore"], env, killSignal: "SIGKILL",
|
|
27
|
+
});
|
|
28
|
+
child.unref();
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
let settled = false;
|
|
32
|
+
const settle = (outcome) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
child.stdout.destroy();
|
|
38
|
+
resolveOutcome({ outcome, stdout: Buffer.concat(chunks).toString("utf8") });
|
|
39
|
+
};
|
|
40
|
+
const timer = setTimeout(() => { child.kill("SIGKILL"); settle("no-answer"); }, deadlineMs);
|
|
41
|
+
timer.unref();
|
|
42
|
+
child.stdout.on("data", (chunk) => { size += chunk.length; if (size <= 1024 * 1024)
|
|
43
|
+
chunks.push(chunk); });
|
|
44
|
+
child.once("error", () => settle("failed"));
|
|
45
|
+
child.once("close", code => settle(code === 0 ? "done" : "failed"));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** The production learning runner: learning-child in its own process. */
|
|
49
|
+
export const learningChild = async (input, deadlineMs) => (await ownChild("learning-child", input, deadlineMs)).outcome;
|
|
50
|
+
/** Recover learning for each project in turn, each in its own bounded process. */
|
|
51
|
+
export async function recoverLearningOffThread(input, options = {}) {
|
|
52
|
+
const outcomes = new Map();
|
|
53
|
+
for (const repo of input.repos) {
|
|
54
|
+
outcomes.set(repo, await (options.runner ?? learningChild)({ databaseFile: input.databaseFile, evidenceRoot: input.evidenceRoot, repo }, options.deadlineMs ?? LEARNING_LOOK_MS));
|
|
55
|
+
}
|
|
56
|
+
return outcomes;
|
|
57
|
+
}
|
|
58
|
+
/** One project's first-task suggestions (first-run.ts findFirstTasks: its issues through gh, its TODOs through the
|
|
59
|
+
* hardened git) read in a process of its own. Rejects when it failed or didn't answer: the caller keeps the generic ones. */
|
|
60
|
+
export async function firstTasksOffThread(repo, deadlineMs = FIRST_TASKS_LOOK_MS) {
|
|
61
|
+
const ran = await ownChild("first-tasks-child", { repo }, deadlineMs);
|
|
62
|
+
if (ran.outcome !== "done")
|
|
63
|
+
throw new Error(`the first-task read ${ran.outcome === "no-answer" ? "didn't answer" : "failed"}`);
|
|
64
|
+
const parsed = JSON.parse(ran.stdout);
|
|
65
|
+
if (!Array.isArray(parsed))
|
|
66
|
+
throw new Error("the first-task read answered something else");
|
|
67
|
+
return parsed.flatMap((one) => {
|
|
68
|
+
const row = one;
|
|
69
|
+
return row !== null && typeof row === "object" && (row.source === "issue" || row.source === "todo" || row.source === "generic") && typeof row.label === "string" && typeof row.draft === "string"
|
|
70
|
+
? [{ source: row.source, label: row.label.slice(0, 500), draft: row.draft.slice(0, 4000) }] : [];
|
|
71
|
+
});
|
|
72
|
+
}
|
package/dist/operate.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type Store } from "./store.js";
|
|
|
9
9
|
import { type DispatchAdapter } from "./sync.js";
|
|
10
10
|
import { type RolePort, type UpRoleStart } from "./up-processes.js";
|
|
11
11
|
import { type TelegramTransport } from "./telegram.js";
|
|
12
|
+
import { type FolderLookOptions } from "./folder-look.js";
|
|
12
13
|
import { type LeadCliSeams } from "./lead-cli.js";
|
|
13
14
|
import { type Principal, type RemoteRun, type RemoteSource } from "./operate-remote.js";
|
|
14
15
|
import { type PublishExec } from "./publish.js";
|
|
@@ -271,3 +272,34 @@ export declare function refreshGithubNightlies(store: Store, now: () => Date, re
|
|
|
271
272
|
* - `recover [--abandon <campaign>]`: settle campaigns whose runner stopped; with --abandon, a person ends a stuck one.
|
|
272
273
|
*/
|
|
273
274
|
export declare function evalsCommand(positional: readonly string[], flags: Map<string, string | true>, context: Context): Promise<number>;
|
|
275
|
+
/**
|
|
276
|
+
* The saved projects an `up` start couldn't reach (upCommand): each is proved again every `everyMs` while the worker
|
|
277
|
+
* runs, by `prove` (upProveSaved), so a folder that answers again connects without a restart. Only what the start itself
|
|
278
|
+
* read from the saved list is here, and the set only shrinks: a line connected, removed from the saved list
|
|
279
|
+
* (`keepOnly`) or come back as some other checkout (`revoke`) is gone for good, so a line saved again later needs its
|
|
280
|
+
* recorded admission like any other addition. `retry` answers the proof when one is due and answered, else null.
|
|
281
|
+
*/
|
|
282
|
+
export declare function upDeferredRetry<T>(inputs: readonly string[], prove: (input: string) => Promise<T | null>, everyMs?: number, now?: () => number): {
|
|
283
|
+
has: (input: string) => boolean;
|
|
284
|
+
list: () => string[];
|
|
285
|
+
retry: (input: string) => Promise<T | null>;
|
|
286
|
+
revoke: (input: string) => void;
|
|
287
|
+
keepOnly: (saved: readonly string[]) => boolean;
|
|
288
|
+
};
|
|
289
|
+
/**
|
|
290
|
+
* Whether a saved project's folder is a Git checkout now, read without blocking this thread: `git rev-parse
|
|
291
|
+
* --show-toplevel` (bounded, its own process), then the real paths of the saved line and of the checkout, resolved off
|
|
292
|
+
* the thread under a deadline (folder-look.ts). Null when it isn't one, or didn't answer.
|
|
293
|
+
*/
|
|
294
|
+
export declare function upProveSaved(gitRun: (file: string, args: readonly string[], opts?: {
|
|
295
|
+
cwd?: string;
|
|
296
|
+
}) => Promise<{
|
|
297
|
+
code: number | null;
|
|
298
|
+
stdout: string;
|
|
299
|
+
}>, input: string, look?: FolderLookOptions): Promise<{
|
|
300
|
+
line: string;
|
|
301
|
+
root: string;
|
|
302
|
+
} | null>;
|
|
303
|
+
/** What `up` hands its next worker after the worker reports which saved projects it still holds start authority for:
|
|
304
|
+
* only what both still hold. A worker's report can give up an authority, never add or give back one. */
|
|
305
|
+
export declare function upNarrowDeferred(held: readonly string[], reported: readonly string[]): string[];
|