toolroll 0.9.97 → 0.9.98
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 +10 -0
- package/dist/approval-authority.d.ts +10 -1
- package/dist/approval-authority.js +18 -2
- package/dist/browser/workspace.js +1 -1
- package/dist/browser-workspace.d.ts +4 -3
- package/dist/chat-actions.d.ts +40 -5
- package/dist/chat-actions.js +124 -13
- package/dist/chat-card.js +4 -1
- package/dist/chat-channel.d.ts +3 -0
- package/dist/chat-channel.js +24 -7
- package/dist/chat-decide.js +4 -2
- package/dist/chat-task-actions.js +1 -1
- package/dist/chat-terms.d.ts +8 -0
- package/dist/chat-terms.js +14 -0
- package/dist/chat-voice.js +1 -1
- package/dist/contracts/chat-actions.d.ts +6 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +6 -0
- package/dist/contracts/cli.js +3 -0
- package/dist/contracts/lead-tools.d.ts +17 -0
- package/dist/contracts/lead-tools.js +4 -1
- package/dist/contracts/store-json.d.ts +1 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/fast-start.d.ts +83 -6
- package/dist/fast-start.js +112 -15
- package/dist/home-groups.d.ts +39 -0
- package/dist/home-groups.js +33 -0
- package/dist/lead-answer-eval.d.ts +7 -0
- package/dist/lead-answer-eval.js +6 -2
- package/dist/lead-capabilities.js +1 -1
- package/dist/lead-context.js +2 -2
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +10 -7
- package/dist/lead-doors.d.ts +32 -0
- package/dist/lead-doors.js +80 -14
- package/dist/lead-status.d.ts +3 -0
- package/dist/lead-status.js +8 -3
- package/dist/lead-tools.d.ts +20 -0
- package/dist/lead-tools.js +66 -3
- package/dist/lead-voice.js +1 -1
- package/dist/lead.js +18 -2
- package/dist/local-owner.js +2 -0
- package/dist/one-step-chat-schema.d.ts +22 -0
- package/dist/one-step-chat-schema.js +37 -0
- package/dist/operate.d.ts +27 -1
- package/dist/operate.js +206 -32
- package/dist/project-delete.js +9 -0
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +1 -1
- package/dist/pull-request-flow.d.ts +4 -0
- package/dist/pull-request-flow.js +16 -1
- package/dist/reply-voice.d.ts +23 -0
- package/dist/reply-voice.js +37 -0
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +3 -2
- package/dist/server/chat.js +4 -4
- package/dist/server/chrome.js +2 -2
- package/dist/server/home.d.ts +4 -3
- package/dist/server/home.js +33 -28
- package/dist/server/render-chat.js +11 -6
- package/dist/server/render-pages.js +2 -2
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +4 -2
- package/dist/store.js +35 -12
- package/dist/telegram-lead.js +69 -5
- package/dist/telegram-status.js +9 -4
- package/dist/telegram.d.ts +10 -0
- package/dist/telegram.js +92 -8
- package/dist/toolroll-update.js +3 -1
- package/dist/tui-contract.d.ts +2 -0
- package/dist/tui-source.js +3 -0
- package/dist/tui.js +41 -41
- package/dist/typed-start.d.ts +51 -0
- package/dist/typed-start.js +98 -0
- package/dist/wake-hub.d.ts +72 -0
- package/dist/wake-hub.js +151 -0
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
/** The card's last line on Telegram, outside the terms (terms are digest-bound), in Toolroll's fixed words. */
|
|
3
|
+
export declare const TYPED_START_LINE = "Tap Start work, or reply yes.";
|
|
4
|
+
/** What a yes that counted for nothing says. It is never passed to the lead. */
|
|
5
|
+
export declare const TYPED_START_REFUSED = "That yes didn't start anything. Tap Start work on the card.";
|
|
6
|
+
/** Whether a typed message is exactly one of the fixed yes words: trimmed, lower-cased, one trailing `.` or `!` removed.
|
|
7
|
+
* Anything longer or different ("yes but make it blue", "ok", "sure") is an ordinary message to the lead. */
|
|
8
|
+
export declare function typedYes(text: string): boolean;
|
|
9
|
+
export type TypedStartCard = {
|
|
10
|
+
proposal: number;
|
|
11
|
+
cardMessage: string;
|
|
12
|
+
stamp: string;
|
|
13
|
+
how: "reply" | "next";
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The Start card a yes in this chat answers, by ids, or null. Every condition from the plan:
|
|
17
|
+
* - the chat's last sent card is a card for a `task_start` proposal: sent (a confirmed message id), never
|
|
18
|
+
* uncertain, the last part of its event, sent with its buttons and the fixed "reply yes" line, its terms whole on the
|
|
19
|
+
* card (sharedActionAllowsChallenge);
|
|
20
|
+
* - the message is a Telegram reply to that card's message (`reply`), or replies to nothing and its message id is the
|
|
21
|
+
* card's plus one (`next`): nothing at all was sent or typed in the chat between them;
|
|
22
|
+
* - the proposal is pending, the only pending proposal in the person's own thread with the lead (never a team room),
|
|
23
|
+
* drafted in a turn that answered (one finalized after a timeout doesn't qualify).
|
|
24
|
+
*/
|
|
25
|
+
export declare function typedStartCard(store: Store, chat: {
|
|
26
|
+
binding: number;
|
|
27
|
+
chatId: string;
|
|
28
|
+
approver: string;
|
|
29
|
+
}, message: {
|
|
30
|
+
id: string;
|
|
31
|
+
replyTo: string | null;
|
|
32
|
+
}): TypedStartCard | null;
|
|
33
|
+
/** Record a typed yes for the message just saved (event `m<update_id>`), in the caller's transaction. */
|
|
34
|
+
export declare function recordTypedStart(store: Store, event: string, card: TypedStartCard, now: Date): void;
|
|
35
|
+
export type TypedStartRow = {
|
|
36
|
+
provider: "telegram";
|
|
37
|
+
event: string;
|
|
38
|
+
proposal: number;
|
|
39
|
+
cardMessage: string;
|
|
40
|
+
stamp: string;
|
|
41
|
+
how: "reply" | "next";
|
|
42
|
+
outcome: "started" | "refused" | "not-counted" | null;
|
|
43
|
+
taskId: string | null;
|
|
44
|
+
};
|
|
45
|
+
/** A message's typed-yes record, or null when it has none. */
|
|
46
|
+
export declare function typedStartOf(store: Store, provider: "telegram", event: string): TypedStartRow | null;
|
|
47
|
+
/** Whether an UNSETTLED typed yes names exactly this proposal: the only thing `typedStart` may open, and only once. */
|
|
48
|
+
export declare function typedStartNames(store: Store, typed: {
|
|
49
|
+
provider: string;
|
|
50
|
+
event: string;
|
|
51
|
+
}, proposal: number): boolean;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed yes to a Start card, on Telegram (docs/plans/one-step-chat.md C5). Toolroll reads the word when the message
|
|
3
|
+
* arrives, before any model runs: the lead never sees it as an instruction and has no tool that presses a card. Which
|
|
4
|
+
* card a yes belongs to comes from Telegram's own message ids and Toolroll's delivery record (chat_part), never from the
|
|
5
|
+
* text or the lead.
|
|
6
|
+
*
|
|
7
|
+
* Read at ingress (telegram.ts applyConversation, the typed-message path only) in the transaction that saves the message,
|
|
8
|
+
* and read again from the tables by confirmTypedStart (lead-doors.ts) in the transaction that acts on it.
|
|
9
|
+
*/
|
|
10
|
+
import { sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
|
|
11
|
+
/** The card's last line on Telegram, outside the terms (terms are digest-bound), in Toolroll's fixed words. */
|
|
12
|
+
export const TYPED_START_LINE = "Tap Start work, or reply yes.";
|
|
13
|
+
/** What a yes that counted for nothing says. It is never passed to the lead. */
|
|
14
|
+
export const TYPED_START_REFUSED = "That yes didn't start anything. Tap Start work on the card.";
|
|
15
|
+
const WORDS = new Set(["yes", "y", "yep", "yeah", "yes please", "yes start", "start", "start it", "go ahead", "do it"]);
|
|
16
|
+
/** Whether a typed message is exactly one of the fixed yes words: trimmed, lower-cased, one trailing `.` or `!` removed.
|
|
17
|
+
* Anything longer or different ("yes but make it blue", "ok", "sure") is an ordinary message to the lead. */
|
|
18
|
+
export function typedYes(text) {
|
|
19
|
+
const word = text.trim().toLowerCase().replace(/[.!]$/, "");
|
|
20
|
+
return WORDS.has(word);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The Start card a yes in this chat answers, by ids, or null. Every condition from the plan:
|
|
24
|
+
* - the chat's last sent card is a card for a `task_start` proposal: sent (a confirmed message id), never
|
|
25
|
+
* uncertain, the last part of its event, sent with its buttons and the fixed "reply yes" line, its terms whole on the
|
|
26
|
+
* card (sharedActionAllowsChallenge);
|
|
27
|
+
* - the message is a Telegram reply to that card's message (`reply`), or replies to nothing and its message id is the
|
|
28
|
+
* card's plus one (`next`): nothing at all was sent or typed in the chat between them;
|
|
29
|
+
* - the proposal is pending, the only pending proposal in the person's own thread with the lead (never a team room),
|
|
30
|
+
* drafted in a turn that answered (one finalized after a timeout doesn't qualify).
|
|
31
|
+
*/
|
|
32
|
+
export function typedStartCard(store, chat, message) {
|
|
33
|
+
const own = /^[1-9][0-9]{0,15}$/;
|
|
34
|
+
if (!own.test(message.id) || (message.replyTo !== null && !own.test(message.replyTo)))
|
|
35
|
+
return null;
|
|
36
|
+
// The chat's last sent card, by Telegram's own message id (ids in one private chat run in order, both directions).
|
|
37
|
+
const last = store.handle.prepare(`SELECT p.id, p.event, p.ordinal, p.payload, p.message, p.uncertain FROM chat_part p
|
|
38
|
+
JOIN chat_event e ON e.provider = p.provider AND e.id = p.event
|
|
39
|
+
WHERE p.provider = 'telegram' AND e.binding = ? AND e.channel = ? AND p.state = 'sent' AND p.message IS NOT NULL
|
|
40
|
+
AND json_valid(p.payload) AND json_extract(p.payload, '$.card') = 1
|
|
41
|
+
ORDER BY CAST(p.message AS INTEGER) DESC LIMIT 1`).get(chat.binding, chat.chatId);
|
|
42
|
+
if (last === undefined)
|
|
43
|
+
return null;
|
|
44
|
+
const cardMessage = String(last["message"]);
|
|
45
|
+
if (!own.test(cardMessage) || Number(last["uncertain"]) !== 0)
|
|
46
|
+
return null;
|
|
47
|
+
const how = message.replyTo === cardMessage ? "reply" : message.replyTo === null && BigInt(message.id) === BigInt(cardMessage) + 1n ? "next" : null;
|
|
48
|
+
if (how === null)
|
|
49
|
+
return null;
|
|
50
|
+
const lastOrdinal = store.handle.prepare("SELECT MAX(ordinal) AS n FROM chat_part WHERE provider = 'telegram' AND event = ?").get(String(last["event"]))?.["n"];
|
|
51
|
+
if (Number(lastOrdinal) !== Number(last["ordinal"]))
|
|
52
|
+
return null;
|
|
53
|
+
let content;
|
|
54
|
+
try {
|
|
55
|
+
content = JSON.parse(String(last["payload"]));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (content.card !== true || typeof content.proposal !== "number" || !Array.isArray(content.keyboard) || typeof content.text !== "string" || !content.text.trimEnd().endsWith(TYPED_START_LINE))
|
|
61
|
+
return null;
|
|
62
|
+
const proposal = store.getLeadProposal(content.proposal);
|
|
63
|
+
const action = proposal?.kind === "action" ? sharedActionPayload(proposal.payload) : null;
|
|
64
|
+
if (proposal === null || action === null || action.operation !== "task_start" || !sharedActionAllowsChallenge(action, "telegram") || proposal.state !== "pending")
|
|
65
|
+
return null;
|
|
66
|
+
const thread = store.getLeadThread(proposal.thread);
|
|
67
|
+
if (thread === null || thread.approver !== chat.approver || thread.closedAt !== null)
|
|
68
|
+
return null;
|
|
69
|
+
if (store.handle.prepare("SELECT 1 AS hit FROM team_conversation WHERE thread = ?").get(proposal.thread) !== undefined)
|
|
70
|
+
return null;
|
|
71
|
+
if (store.getLeadTurn(proposal.turn)?.state !== "answered")
|
|
72
|
+
return null;
|
|
73
|
+
const pending = Number(store.handle.prepare("SELECT COUNT(*) AS n FROM lead_proposal WHERE thread = ? AND state = 'pending'").get(proposal.thread)?.["n"]);
|
|
74
|
+
if (pending !== 1)
|
|
75
|
+
return null;
|
|
76
|
+
return { proposal: proposal.id, cardMessage, stamp: action.stamp, how };
|
|
77
|
+
}
|
|
78
|
+
/** Record a typed yes for the message just saved (event `m<update_id>`), in the caller's transaction. */
|
|
79
|
+
export function recordTypedStart(store, event, card, now) {
|
|
80
|
+
store.handle.prepare(`INSERT INTO chat_typed_start (provider, event, proposal, card_message, stamp, how, created) VALUES ('telegram', ?, ?, ?, ?, ?, ?)`)
|
|
81
|
+
.run(event, card.proposal, card.cardMessage, card.stamp, card.how, now.toISOString());
|
|
82
|
+
}
|
|
83
|
+
/** A message's typed-yes record, or null when it has none. */
|
|
84
|
+
export function typedStartOf(store, provider, event) {
|
|
85
|
+
const row = store.handle.prepare("SELECT * FROM chat_typed_start WHERE provider = ? AND event = ?").get(provider, event);
|
|
86
|
+
if (row === undefined)
|
|
87
|
+
return null;
|
|
88
|
+
return { provider, event, proposal: Number(row["proposal"]), cardMessage: String(row["card_message"]), stamp: String(row["stamp"]), how: row["how"] === "reply" ? "reply" : "next",
|
|
89
|
+
outcome: row["outcome"] === "started" || row["outcome"] === "refused" || row["outcome"] === "not-counted" ? row["outcome"] : null,
|
|
90
|
+
taskId: row["task_id"] === null ? null : String(row["task_id"]) };
|
|
91
|
+
}
|
|
92
|
+
/** Whether an UNSETTLED typed yes names exactly this proposal: the only thing `typedStart` may open, and only once. */
|
|
93
|
+
export function typedStartNames(store, typed, proposal) {
|
|
94
|
+
if (typed.provider !== "telegram" || typeof typed.event !== "string")
|
|
95
|
+
return false;
|
|
96
|
+
const row = typedStartOf(store, "telegram", typed.event);
|
|
97
|
+
return row !== null && row.outcome === null && row.proposal === proposal;
|
|
98
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shared wake poller per process (docs/plans/live-wakeups.md, slice 3).
|
|
3
|
+
*
|
|
4
|
+
* Every dispatch loop and build lane used to doze by reading `wakeSeq()` from SQLite every 50 ms on its own, so a
|
|
5
|
+
* service with twelve projects and eight lanes made hundreds of reads a second while idle. The hub keeps ONE timer
|
|
6
|
+
* per store: it runs only while something waits, reads the counter once per beat, and resolves each waiter whose
|
|
7
|
+
* baseline the counter has passed.
|
|
8
|
+
*
|
|
9
|
+
* - A waiter resolves with why: "wake" (the counter moved past its baseline), "deadline" (its time came), or "poke"
|
|
10
|
+
* (its `until` became true, or someone poked it directly: a stop, a lost lease, a recovered reconciliation).
|
|
11
|
+
* - No lost wakeup. The counter only ever moves forward, so a bump that lands after the caller read its baseline and
|
|
12
|
+
* before it registered is seen on the very next read. Registration is synchronous; nothing yields in between.
|
|
13
|
+
* - Every waiter is checked on every beat (50 ms, well inside the one-second floor the plan asks for), including its
|
|
14
|
+
* `until`, which must be cheap and in-memory: it never reads the database.
|
|
15
|
+
* - The hub only gives promptness, never permission: claim and spawn fences re-prove everything after a wake.
|
|
16
|
+
* - Nothing here watches files or listens to commits. The timer is cleared as soon as nobody waits, so a stopped
|
|
17
|
+
* worker (or a finished test) keeps no timer alive.
|
|
18
|
+
*/
|
|
19
|
+
export type WakeWhy = "wake" | "deadline" | "poke";
|
|
20
|
+
/** Something a caller can poke to end the waits registered with it, now (a stop, a lost lease, a recovery). */
|
|
21
|
+
export declare class WakePoker {
|
|
22
|
+
private readonly listeners;
|
|
23
|
+
poke(): void;
|
|
24
|
+
/** @internal */
|
|
25
|
+
listen(listener: () => void): () => void;
|
|
26
|
+
}
|
|
27
|
+
export type WakeWaiter = {
|
|
28
|
+
/** The counter value the caller last acted on. Any later value wakes it. */
|
|
29
|
+
baseline: number;
|
|
30
|
+
/** Epoch milliseconds: resolve with "deadline" once the clock reaches it. */
|
|
31
|
+
deadline: number;
|
|
32
|
+
/** A cheap, in-memory condition checked every beat (never a database read); true resolves with "poke". */
|
|
33
|
+
until?: () => boolean;
|
|
34
|
+
/** Pokes on this end the wait at once with "poke". */
|
|
35
|
+
poker?: WakePoker;
|
|
36
|
+
};
|
|
37
|
+
export type WakeHubOptions = {
|
|
38
|
+
/** How often the one timer reads the counter while something waits. */
|
|
39
|
+
intervalMs?: number;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
42
|
+
clearTimer?: (handle: unknown) => void;
|
|
43
|
+
};
|
|
44
|
+
export declare const WAKE_HUB_INTERVAL_MS = 50;
|
|
45
|
+
export declare class WakeHub {
|
|
46
|
+
private readonly read;
|
|
47
|
+
private readonly waiters;
|
|
48
|
+
private timer;
|
|
49
|
+
private readonly intervalMs;
|
|
50
|
+
private readonly now;
|
|
51
|
+
private readonly setTimer;
|
|
52
|
+
private readonly clearTimer;
|
|
53
|
+
private readsMade;
|
|
54
|
+
constructor(read: () => number, options?: WakeHubOptions);
|
|
55
|
+
/** Wait until the counter passes `baseline`, the deadline comes, or the waiter is poked. */
|
|
56
|
+
changed(waiter: WakeWaiter): Promise<WakeWhy>;
|
|
57
|
+
/** End every wait in this process now (the service's stop signal). */
|
|
58
|
+
poke(): void;
|
|
59
|
+
/** How many waits are open (tests and diagnostics). */
|
|
60
|
+
waiting(): number;
|
|
61
|
+
/** Whether the beat timer is running (tests). */
|
|
62
|
+
running(): boolean;
|
|
63
|
+
/** How many counter reads the hub has made (tests: one per beat, whatever the number of waiters). */
|
|
64
|
+
reads(): number;
|
|
65
|
+
private arm;
|
|
66
|
+
private idleIfEmpty;
|
|
67
|
+
private beat;
|
|
68
|
+
}
|
|
69
|
+
/** The one hub for this store (one per process under `up`, which shares a store across project loops). */
|
|
70
|
+
export declare function wakeHubFor(store: {
|
|
71
|
+
wakeSeq(): number;
|
|
72
|
+
}): WakeHub;
|
package/dist/wake-hub.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shared wake poller per process (docs/plans/live-wakeups.md, slice 3).
|
|
3
|
+
*
|
|
4
|
+
* Every dispatch loop and build lane used to doze by reading `wakeSeq()` from SQLite every 50 ms on its own, so a
|
|
5
|
+
* service with twelve projects and eight lanes made hundreds of reads a second while idle. The hub keeps ONE timer
|
|
6
|
+
* per store: it runs only while something waits, reads the counter once per beat, and resolves each waiter whose
|
|
7
|
+
* baseline the counter has passed.
|
|
8
|
+
*
|
|
9
|
+
* - A waiter resolves with why: "wake" (the counter moved past its baseline), "deadline" (its time came), or "poke"
|
|
10
|
+
* (its `until` became true, or someone poked it directly: a stop, a lost lease, a recovered reconciliation).
|
|
11
|
+
* - No lost wakeup. The counter only ever moves forward, so a bump that lands after the caller read its baseline and
|
|
12
|
+
* before it registered is seen on the very next read. Registration is synchronous; nothing yields in between.
|
|
13
|
+
* - Every waiter is checked on every beat (50 ms, well inside the one-second floor the plan asks for), including its
|
|
14
|
+
* `until`, which must be cheap and in-memory: it never reads the database.
|
|
15
|
+
* - The hub only gives promptness, never permission: claim and spawn fences re-prove everything after a wake.
|
|
16
|
+
* - Nothing here watches files or listens to commits. The timer is cleared as soon as nobody waits, so a stopped
|
|
17
|
+
* worker (or a finished test) keeps no timer alive.
|
|
18
|
+
*/
|
|
19
|
+
/** Something a caller can poke to end the waits registered with it, now (a stop, a lost lease, a recovery). */
|
|
20
|
+
export class WakePoker {
|
|
21
|
+
listeners = new Set();
|
|
22
|
+
poke() {
|
|
23
|
+
for (const listener of [...this.listeners])
|
|
24
|
+
listener();
|
|
25
|
+
}
|
|
26
|
+
/** @internal */
|
|
27
|
+
listen(listener) {
|
|
28
|
+
this.listeners.add(listener);
|
|
29
|
+
return () => { this.listeners.delete(listener); };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const WAKE_HUB_INTERVAL_MS = 50;
|
|
33
|
+
export class WakeHub {
|
|
34
|
+
read;
|
|
35
|
+
waiters = new Set();
|
|
36
|
+
timer = null;
|
|
37
|
+
intervalMs;
|
|
38
|
+
now;
|
|
39
|
+
setTimer;
|
|
40
|
+
clearTimer;
|
|
41
|
+
readsMade = 0;
|
|
42
|
+
constructor(read, options = {}) {
|
|
43
|
+
this.read = read;
|
|
44
|
+
this.intervalMs = options.intervalMs ?? WAKE_HUB_INTERVAL_MS;
|
|
45
|
+
this.now = options.now ?? Date.now;
|
|
46
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
47
|
+
this.clearTimer = options.clearTimer ?? (handle => clearTimeout(handle));
|
|
48
|
+
}
|
|
49
|
+
/** Wait until the counter passes `baseline`, the deadline comes, or the waiter is poked. */
|
|
50
|
+
changed(waiter) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let unlisten = () => { };
|
|
53
|
+
const entry = {
|
|
54
|
+
...waiter,
|
|
55
|
+
settle: why => {
|
|
56
|
+
if (!this.waiters.delete(entry))
|
|
57
|
+
return;
|
|
58
|
+
unlisten();
|
|
59
|
+
this.idleIfEmpty();
|
|
60
|
+
resolve(why);
|
|
61
|
+
},
|
|
62
|
+
fail: error => {
|
|
63
|
+
if (!this.waiters.delete(entry))
|
|
64
|
+
return;
|
|
65
|
+
unlisten();
|
|
66
|
+
this.idleIfEmpty();
|
|
67
|
+
reject(error);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
this.waiters.add(entry);
|
|
71
|
+
if (waiter.poker !== undefined)
|
|
72
|
+
unlisten = waiter.poker.listen(() => entry.settle("poke"));
|
|
73
|
+
// Already true (a stop that landed before the wait, a deadline in the past): no beat needed.
|
|
74
|
+
if (waiter.until?.() === true) {
|
|
75
|
+
entry.settle("poke");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (this.now() >= waiter.deadline) {
|
|
79
|
+
entry.settle("deadline");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.arm();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** End every wait in this process now (the service's stop signal). */
|
|
86
|
+
poke() {
|
|
87
|
+
for (const entry of [...this.waiters])
|
|
88
|
+
entry.settle("poke");
|
|
89
|
+
}
|
|
90
|
+
/** How many waits are open (tests and diagnostics). */
|
|
91
|
+
waiting() { return this.waiters.size; }
|
|
92
|
+
/** Whether the beat timer is running (tests). */
|
|
93
|
+
running() { return this.timer !== null; }
|
|
94
|
+
/** How many counter reads the hub has made (tests: one per beat, whatever the number of waiters). */
|
|
95
|
+
reads() { return this.readsMade; }
|
|
96
|
+
arm() {
|
|
97
|
+
if (this.timer !== null || this.waiters.size === 0)
|
|
98
|
+
return;
|
|
99
|
+
this.timer = this.setTimer(() => this.beat(), this.intervalMs);
|
|
100
|
+
}
|
|
101
|
+
idleIfEmpty() {
|
|
102
|
+
if (this.waiters.size > 0 || this.timer === null)
|
|
103
|
+
return;
|
|
104
|
+
this.clearTimer(this.timer);
|
|
105
|
+
this.timer = null;
|
|
106
|
+
}
|
|
107
|
+
beat() {
|
|
108
|
+
this.timer = null;
|
|
109
|
+
if (this.waiters.size === 0)
|
|
110
|
+
return;
|
|
111
|
+
let seq;
|
|
112
|
+
try {
|
|
113
|
+
this.readsMade++;
|
|
114
|
+
seq = this.read();
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// A read that fails reaches every waiter, as the loops' own read used to: nothing is silently swallowed.
|
|
118
|
+
for (const entry of [...this.waiters])
|
|
119
|
+
entry.fail(error);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const now = this.now();
|
|
123
|
+
for (const entry of [...this.waiters]) {
|
|
124
|
+
let stop = false;
|
|
125
|
+
try {
|
|
126
|
+
stop = entry.until?.() === true;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
entry.fail(error);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (stop)
|
|
133
|
+
entry.settle("poke");
|
|
134
|
+
else if (seq !== entry.baseline)
|
|
135
|
+
entry.settle("wake");
|
|
136
|
+
else if (now >= entry.deadline)
|
|
137
|
+
entry.settle("deadline");
|
|
138
|
+
}
|
|
139
|
+
this.arm();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const hubs = new WeakMap();
|
|
143
|
+
/** The one hub for this store (one per process under `up`, which shares a store across project loops). */
|
|
144
|
+
export function wakeHubFor(store) {
|
|
145
|
+
let hub = hubs.get(store);
|
|
146
|
+
if (hub === undefined) {
|
|
147
|
+
hub = new WakeHub(() => store.wakeSeq());
|
|
148
|
+
hubs.set(store, hub);
|
|
149
|
+
}
|
|
150
|
+
return hub;
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.98",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|