@unoverse-platform/studio 0.1.115 → 0.1.117
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/local/catalog.ts +3 -0
- package/local/plugin.ts +12 -9
- package/local/scaffold.d.mts +1 -0
- package/local/scaffold.mjs +12 -0
- package/package.json +1 -1
- package/screens/AgentChain.tsx +232 -0
- package/screens/AgentsView.tsx +85 -6
- package/screens/SkillsView.tsx +21 -3
- package/screens/TemplatesLibraryView.tsx +32 -3
- package/screens/TemplatesView.tsx +2 -2
- package/screens/index.ts +4 -1
- package/src/host.ts +5 -0
- package/src/index.css +11 -0
- package/vendor/base/src/agent-mcp/discovery.ts +5 -2
- package/vendor/base/src/agent-mcp/index.ts +3 -1
- package/vendor/base/src/agent-mcp/invoke.ts +199 -25
- package/vendor/base/src/agent-mcp/progressive.ts +124 -0
- package/vendor/base/src/agent-mcp/tools.ts +18 -11
- package/vendor/base/src/agent-mcp/types.ts +8 -0
- package/vendor/base/src/auth/agentSwitch.ts +52 -0
- package/vendor/base/src/auth/agentToken.ts +142 -0
- package/vendor/base/src/auth/auth.ts +7 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
- package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
- package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
- package/vendor/base/src/definitions/load/load.ts +6 -1
- package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
- package/vendor/base/src/definitions/registry/catalog.ts +1 -1
- package/vendor/base/src/definitions/registry/objects.ts +109 -5
- package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
- package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
- package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
- package/vendor/base/src/items/collect.ts +10 -0
- package/vendor/base/src/items/publish.ts +10 -0
- package/vendor/base/src/lint/design/index.mjs +14 -3
- package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/src/lint/objects/index.d.mts +1 -0
- package/vendor/base/src/lint/objects/index.mjs +121 -3
- package/vendor/base/src/lint/objects/rules.d.mts +3 -0
- package/vendor/base/src/lint/objects/rules.mjs +106 -7
- package/vendor/base/src/manifests/compose.ts +3 -0
- package/vendor/base/src/manifests/executor/callback.ts +3 -2
- package/vendor/base/src/manifests/executor/memory.ts +27 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
- package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
- package/vendor/base/src/manifests/runtime/events.ts +1 -1
- package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
- package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
- package/vendor/base/src/manifests/runtime/usage.ts +13 -5
- package/vendor/base/src/platform/agentBudget.ts +65 -0
- package/vendor/base/src/platform/agentCalendar.ts +87 -0
- package/vendor/base/src/platform/inbound.ts +67 -0
- package/vendor/base/src/platform/taskChanged.ts +25 -0
- package/vendor/base/src/platform/tokenCost.ts +414 -0
- package/vendor/base/src/security/permissions.ts +10 -2
- package/vendor/sdk/appEngine.ts +42 -1
- package/vendor/sdk/lib/core/actions.ts +10 -0
- package/vendor/sdk/lib/core/apps.ts +2 -2
- package/vendor/sdk/lib/core/client.ts +11 -0
- package/vendor/sdk/lib/core/store.ts +1 -1
- package/vendor/sdk/lib/core/types.ts +2 -0
- package/vendor/sdk/lib/dom/app.ts +3 -2
- package/vendor/sdk/lib/dom/component.ts +38 -1
- package/vendor/sdk/lib/dom/element.ts +23 -0
- package/vendor/sdk/lib/dom/markdown.ts +6 -2
- package/vendor/sdk/lib/dom/primitives.ts +97 -6
- package/vendor/sdk/lib/voiceService.ts +3 -1
- package/vendor/sdk/main.ts +1 -1
- package/vendor/sdk/pageInterfaces.ts +109 -8
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* Sniffing those three is a wire-format rule, not a per-node rule — no manifest changes.
|
|
16
16
|
*
|
|
17
17
|
* A run may hold SEVERAL model calls (the tool loop's turns), each with its own usage
|
|
18
|
-
* block; the collector SUMS numeric fields across
|
|
19
|
-
*
|
|
18
|
+
* block; the collector SUMS numeric fields across what it saw since its last save, and the
|
|
19
|
+
* rows for one execution add up to what it cost. Fire-and-forget on save, same contract as the MCP trace:
|
|
20
20
|
* observability must never slow a run or fail it.
|
|
21
21
|
*/
|
|
22
22
|
import type { ComposedNode } from "../compose.js";
|
|
@@ -38,7 +38,12 @@ export interface UsageCollector {
|
|
|
38
38
|
* the length. A vendor that reports running totals (GPT-Live) would otherwise be summed.
|
|
39
39
|
*/
|
|
40
40
|
set(field: string, value: number): void;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Post what was seen SINCE THE LAST SAVE to the engine, then start again from nothing. Called
|
|
43
|
+
* after each turn of a tool loop, so spend is on record while the run is still going: a budget
|
|
44
|
+
* read mid-run (an Agent's, AGENTS.md § 9) saw nothing until the loop settled. Nothing new, or no
|
|
45
|
+
* execution scope → no-op.
|
|
46
|
+
*/
|
|
42
47
|
save(): void;
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -61,7 +66,7 @@ function addInto(totals: Record<string, any>, block: Record<string, unknown>): v
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCollector {
|
|
64
|
-
|
|
69
|
+
let totals: Record<string, number | Record<string, number>> = {};
|
|
65
70
|
let seen = false;
|
|
66
71
|
let model: string | undefined;
|
|
67
72
|
|
|
@@ -80,6 +85,9 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
|
|
|
80
85
|
},
|
|
81
86
|
save() {
|
|
82
87
|
if (!seen) return;
|
|
88
|
+
const spent = totals;
|
|
89
|
+
totals = {};
|
|
90
|
+
seen = false;
|
|
83
91
|
const { workflowId, executionId, nodeId } = ctx.scope ?? {};
|
|
84
92
|
// Node-test / headless: no execution to attach to (same rule as the MCP trace).
|
|
85
93
|
if (!workflowId || !executionId || !nodeId) return;
|
|
@@ -89,7 +97,7 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
|
|
|
89
97
|
nodeId,
|
|
90
98
|
nodeType: node.type,
|
|
91
99
|
model: model ?? (ctx.config as any)?.model ?? "unknown",
|
|
92
|
-
usage:
|
|
100
|
+
usage: spent,
|
|
93
101
|
timestamp: new Date().toISOString(),
|
|
94
102
|
});
|
|
95
103
|
},
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN AGENT'S BUDGET (docs/architecture/signal/AGENTS.md § 9, § 9a; UNO-222).
|
|
3
|
+
*
|
|
4
|
+
* Counted, not trusted: what an Agent spent is the platform's own usage rows (`token_usage`) for
|
|
5
|
+
* everything run as the Agent (its own thinking and every task it called), priced by the one table
|
|
6
|
+
* (`tokenCost.ts`). Checked BEFORE every model call and every task, never after:
|
|
7
|
+
*
|
|
8
|
+
* under 80% of a limit go on
|
|
9
|
+
* 80% or more go on, its owner is told once
|
|
10
|
+
* 100% start nothing new
|
|
11
|
+
*
|
|
12
|
+
* Pure: the rows and the limits come in.
|
|
13
|
+
*/
|
|
14
|
+
import { parseTokenUsage, calculateTokenCost } from "./tokenCost.js";
|
|
15
|
+
|
|
16
|
+
/** "20 USD", "20", "$20" → 20. Null when it is not an amount in US dollars. */
|
|
17
|
+
export function dollars(amount: string | undefined): number | null {
|
|
18
|
+
if (!amount) return null;
|
|
19
|
+
const m = /^\s*\$?\s*(\d+(?:\.\d+)?)\s*(USD)?\s*$/i.exec(amount);
|
|
20
|
+
return m ? Number(m[1]) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One usage row as the platform keeps it. */
|
|
24
|
+
export interface UsageRow {
|
|
25
|
+
model: string | null;
|
|
26
|
+
usage: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What these rows cost, in US dollars. */
|
|
30
|
+
export function spent(rows: UsageRow[]): number {
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
if (!row.model) continue;
|
|
34
|
+
total += Number(calculateTokenCost(row.model, parseTokenUsage(row.model, row.usage)));
|
|
35
|
+
}
|
|
36
|
+
return Math.round(total * 10000) / 10000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BudgetLimits {
|
|
40
|
+
perDay?: string;
|
|
41
|
+
perRun?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type BudgetCheck =
|
|
45
|
+
| { go: true; near?: "day" | "run"; message?: string }
|
|
46
|
+
| { go: false; limit: "day" | "run" | "none"; message: string };
|
|
47
|
+
|
|
48
|
+
/** May the Agent start one more model call or task? */
|
|
49
|
+
export function checkBudget(limits: BudgetLimits, used: { today: number; run: number }): BudgetCheck {
|
|
50
|
+
const day = dollars(limits.perDay);
|
|
51
|
+
if (day === null) return { go: false, limit: "none", message: "No daily budget: an Agent with no budget.perDay never runs" };
|
|
52
|
+
const run = dollars(limits.perRun);
|
|
53
|
+
if (used.today >= day) return { go: false, limit: "day", message: `Today's budget is spent: $${used.today.toFixed(2)} of $${day.toFixed(2)}. Carries on tomorrow.` };
|
|
54
|
+
if (run !== null && used.run >= run) return { go: false, limit: "run", message: `This run's budget is spent: $${used.run.toFixed(2)} of $${run.toFixed(2)}.` };
|
|
55
|
+
if (used.today >= day * 0.8) return { go: true, near: "day", message: `$${used.today.toFixed(2)} of today's $${day.toFixed(2)} spent` };
|
|
56
|
+
if (run !== null && used.run >= run * 0.8) return { go: true, near: "run", message: `$${used.run.toFixed(2)} of this run's $${run.toFixed(2)} spent` };
|
|
57
|
+
return { go: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Midnight today in the environment's own time zone, where an Agent's day starts. */
|
|
61
|
+
export function startOfToday(now: Date = new Date()): Date {
|
|
62
|
+
const d = new Date(now);
|
|
63
|
+
d.setHours(0, 0, 0, 0);
|
|
64
|
+
return d;
|
|
65
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN AGENT'S DAY IS ITS CALENDAR (docs/architecture/signal/AGENTS.md § 5; UNO-207).
|
|
3
|
+
*
|
|
4
|
+
* A person drops slots into the Agent's calendar: the title names the slot, the description is
|
|
5
|
+
* what to do. Every `checkIn` the calendar is read; a slot that started since the last look, or is
|
|
6
|
+
* under way now, wakes the Agent, once, with the slot as its instruction: its work is a task from the
|
|
7
|
+
* moment it is on the calendar, never something to wait for. These are the rules; the engine's check-in
|
|
8
|
+
* (`engine/src/agentKeys/checkIn.ts`) reads the calendar and wakes it.
|
|
9
|
+
*
|
|
10
|
+
* Only people in its org give it work (§ 7b): a slot made by the mailbox itself, or by someone at
|
|
11
|
+
* the same company domain. A public mail domain (gmail.com and the like) is nobody's company, so
|
|
12
|
+
* there only the mailbox's own slots count. An outside invitation is never an instruction.
|
|
13
|
+
*
|
|
14
|
+
* A slot names the Agent (owner, 2026-10-10: "start with Bob:"): its title begins with the Agent's
|
|
15
|
+
* name and a colon, "Bob: Morning sweep". Every other event in the calendar is a person's own
|
|
16
|
+
* meeting and never wakes it, which matters while it reads a person's calendar rather than its own.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The fields of an event these rules read. */
|
|
20
|
+
export interface SlotEvent {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
description: string;
|
|
24
|
+
start: string;
|
|
25
|
+
/** When it ends; empty for none. A slot under way still wakes an Agent that has not worked it. */
|
|
26
|
+
end?: string | null;
|
|
27
|
+
organizer: { address: string } | null;
|
|
28
|
+
creator: { address: string } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How often its calendar is read when its file says nothing. */
|
|
32
|
+
export const DEFAULT_CHECK_IN_MS = 15 * 60_000;
|
|
33
|
+
|
|
34
|
+
/** "15m", "1h", "30s" → milliseconds; anything else, the default. Never under a minute. */
|
|
35
|
+
export function checkInEvery(checkIn: string | undefined): number {
|
|
36
|
+
const m = /^\s*(\d+)\s*(s|m|h)\s*$/i.exec(checkIn ?? "");
|
|
37
|
+
if (!m) return DEFAULT_CHECK_IN_MS;
|
|
38
|
+
const ms = Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2].toLowerCase() as "s" | "m" | "h"];
|
|
39
|
+
return Math.max(ms, 60_000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PUBLIC_DOMAINS = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", "msn.com", "yahoo.com", "icloud.com", "me.com", "proton.me", "protonmail.com", "aol.com"]);
|
|
43
|
+
const domainOf = (address: string) => address.slice(address.lastIndexOf("@") + 1).toLowerCase();
|
|
44
|
+
|
|
45
|
+
/** Was this slot made by someone in the Agent's org? */
|
|
46
|
+
export function fromOrg(event: SlotEvent, mailbox: string): boolean {
|
|
47
|
+
const maker = (event.creator ?? event.organizer)?.address?.toLowerCase();
|
|
48
|
+
const own = mailbox.trim().toLowerCase();
|
|
49
|
+
if (!maker) return false;
|
|
50
|
+
if (maker === own) return true;
|
|
51
|
+
const domain = domainOf(own);
|
|
52
|
+
return !PUBLIC_DOMAINS.has(domain) && domainOf(maker) === domain;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** "Bob: Morning sweep" for Bob → "Morning sweep"; null when the slot is not addressed to it. */
|
|
56
|
+
export function addressedTo(title: string, agentName: string): string | null {
|
|
57
|
+
const name = agentName.trim().toLowerCase();
|
|
58
|
+
const t = title.trim();
|
|
59
|
+
if (!name || t.slice(0, name.length + 1).toLowerCase() !== `${name}:`) return null;
|
|
60
|
+
return t.slice(name.length + 1).trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The slots to work now, earliest first: started after `since` and by `now`, or started earlier
|
|
64
|
+
* and still under way. Whether one was already worked is the run record's (`slotKey`). */
|
|
65
|
+
export function slotsDue<T extends SlotEvent>(events: T[], since: Date, now: Date): T[] {
|
|
66
|
+
return events
|
|
67
|
+
.filter((e) => {
|
|
68
|
+
const at = Date.parse(e.start);
|
|
69
|
+
if (at > now.getTime()) return false;
|
|
70
|
+
if (at > since.getTime()) return true;
|
|
71
|
+
const end = e.end ? Date.parse(e.end) : NaN;
|
|
72
|
+
return end > now.getTime();
|
|
73
|
+
})
|
|
74
|
+
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One slot, once: a recurring slot's occurrences differ by start. What its run is recorded as. */
|
|
78
|
+
export function slotKey(event: SlotEvent): string {
|
|
79
|
+
return `calendar:${event.id}@${event.start}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What the Agent is told when a slot starts. */
|
|
83
|
+
export function slotInstruction(event: SlotEvent, agentName: string): string {
|
|
84
|
+
const what = event.description.trim();
|
|
85
|
+
const title = addressedTo(event.title, agentName) || event.title.trim() || "(untitled)";
|
|
86
|
+
return `Your calendar: "${title}" has started.${what ? `\n\n${what}` : "\n\nIt has no description: do your job, as your skills say."}`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS THIS MESSAGE GENUINE? (docs/auth-security/INBOUND.md § 2 step 3; UNO-239)
|
|
3
|
+
*
|
|
4
|
+
* The verifiers, written here and nowhere else. A node only NAMES its scheme and the field of its
|
|
5
|
+
* credential holding the secret (`inbound:` in node.yaml); it can never describe its own check, so
|
|
6
|
+
* no node can switch checking off. Each one fails closed: no secret, no timestamp, a wrong or old
|
|
7
|
+
* signature, all refused. Compared in constant time over the raw bytes, before anything is parsed.
|
|
8
|
+
*
|
|
9
|
+
* A service's one-time address check (Slack's `url_verification`) is answered here too, but only
|
|
10
|
+
* once the request has passed the same check: an unsigned challenge is refused like anything else.
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** What a verifier decides. `challenge`: the service is checking the address; echo this, do nothing else. */
|
|
15
|
+
export type Verdict = { ok: false; reason: string } | { ok: true; challenge?: string };
|
|
16
|
+
|
|
17
|
+
/** The schemes a node may name. */
|
|
18
|
+
export const SCHEMES = ["slack"] as const;
|
|
19
|
+
export type Scheme = (typeof SCHEMES)[number];
|
|
20
|
+
|
|
21
|
+
/** Older than this, or this far in the future, and a message is a replay (Slack's own window). */
|
|
22
|
+
export const MAX_SKEW_SECONDS = 300;
|
|
23
|
+
|
|
24
|
+
function same(a: string, b: string): boolean {
|
|
25
|
+
const x = Buffer.from(a);
|
|
26
|
+
const y = Buffer.from(b);
|
|
27
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SLACK (docs.slack.dev, "Verifying requests from Slack"): `X-Slack-Signature` is `v0=` and the hex
|
|
32
|
+
* HMAC-SHA256, keyed by the app's signing secret, of `v0:<X-Slack-Request-Timestamp>:<raw body>`.
|
|
33
|
+
*/
|
|
34
|
+
function slack(headers: Headers, raw: string, secret: string, nowSeconds: number): Verdict {
|
|
35
|
+
const ts = headers.get("x-slack-request-timestamp") ?? "";
|
|
36
|
+
const signature = headers.get("x-slack-signature") ?? "";
|
|
37
|
+
if (!/^\d+$/.test(ts) || !signature) return { ok: false, reason: "unsigned" };
|
|
38
|
+
if (Math.abs(nowSeconds - Number(ts)) > MAX_SKEW_SECONDS) return { ok: false, reason: "too old" };
|
|
39
|
+
const expected = `v0=${createHmac("sha256", secret).update(`v0:${ts}:${raw}`).digest("hex")}`;
|
|
40
|
+
if (!same(signature, expected)) return { ok: false, reason: "wrong signature" };
|
|
41
|
+
try {
|
|
42
|
+
const body = JSON.parse(raw);
|
|
43
|
+
if (body?.type === "url_verification" && typeof body.challenge === "string") return { ok: true, challenge: body.challenge };
|
|
44
|
+
} catch {
|
|
45
|
+
/* a signed body that is not JSON is still genuine; what it says is the next step's to read */
|
|
46
|
+
}
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const VERIFIERS: Record<Scheme, (headers: Headers, raw: string, secret: string, nowSeconds: number) => Verdict> = { slack };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check one message with the scheme its node names. `secrets` holds the current secret and, while
|
|
54
|
+
* one is being replaced, the next: either passing is genuine. No secret at all is a refusal.
|
|
55
|
+
*/
|
|
56
|
+
export function verifyInbound(scheme: string, headers: Headers, raw: string, secrets: Array<string | undefined | null>, nowSeconds = Math.floor(Date.now() / 1000)): Verdict {
|
|
57
|
+
const verify = VERIFIERS[scheme as Scheme];
|
|
58
|
+
if (!verify) return { ok: false, reason: `no verifier for "${scheme}"` };
|
|
59
|
+
const usable = secrets.filter((s): s is string => typeof s === "string" && s.length > 0);
|
|
60
|
+
if (!usable.length) return { ok: false, reason: "no secret" };
|
|
61
|
+
let last: Verdict = { ok: false, reason: "no secret" };
|
|
62
|
+
for (const secret of usable) {
|
|
63
|
+
last = verify(headers, raw, secret, nowSeconds);
|
|
64
|
+
if (last.ok) return last;
|
|
65
|
+
}
|
|
66
|
+
return last;
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "A TASK CHANGED" (docs/mcp/MCP_TASKS.md § 5a, S3; step 6). Whatever writes a task's row says
|
|
3
|
+
* so here, once, after the write: the engine when a run starts and ends, the server when a step
|
|
4
|
+
* is saved or a node starts or stops asking. It lives in base because both write rows, in one
|
|
5
|
+
* process; a client following that task (`notifications/tasks`, server/taskListen.ts) is sent the
|
|
6
|
+
* whole task again. Nothing else lives here.
|
|
7
|
+
*/
|
|
8
|
+
const listeners = new Set<(taskId: string) => void>();
|
|
9
|
+
|
|
10
|
+
/** Listen for changed tasks. Returns the way to stop listening. */
|
|
11
|
+
export function onTaskChanged(listener: (taskId: string) => void): () => void {
|
|
12
|
+
listeners.add(listener);
|
|
13
|
+
return () => listeners.delete(listener);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Say a task's row changed. A listener that fails never stops the others. */
|
|
17
|
+
export function taskChanged(taskId: string): void {
|
|
18
|
+
for (const listener of listeners) {
|
|
19
|
+
try {
|
|
20
|
+
listener(taskId);
|
|
21
|
+
} catch {
|
|
22
|
+
/* one entry's failure is its own */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|