@anchrd/intel-ui 0.18.0 → 0.19.0

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.
Files changed (63) hide show
  1. package/package.json +2 -9
  2. package/src/app/app-tree/app-tree.tsx +59 -147
  3. package/src/app/settings-dialog/settings-dialog.tsx +10 -32
  4. package/src/data/intel-data-provider/intel-data-provider.ts +0 -163
  5. package/src/data/intel-data-provider/intel-data-provider.types.ts +0 -100
  6. package/src/entry-picker/entry-picker.tsx +6 -6
  7. package/src/i18n/de.json +6 -217
  8. package/src/i18n/en.json +6 -217
  9. package/src/i18n/es.json +6 -217
  10. package/src/import-bundle/import-bundle.tsx +152 -0
  11. package/src/kind-icon.ts +1 -15
  12. package/src/main.tsx +7 -10
  13. package/src/node-table/node-table.tsx +2 -2
  14. package/src/nodes/nodes.tsx +0 -34
  15. package/src/resource-menu/resource-menu.tsx +42 -14
  16. package/src/section-hint/section-hint.tsx +2 -2
  17. package/src/title-row/title-row.tsx +5 -22
  18. package/src/tools/tools.tsx +0 -2
  19. package/vite.config.ts +0 -4
  20. package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
  21. package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
  22. package/src/agent/agent-chat/agent-chat.tsx +0 -118
  23. package/src/agent/agent-costs/agent-costs.ts +0 -95
  24. package/src/agent/agent-cron/agent-cron.ts +0 -68
  25. package/src/agent/agent-definition/agent-definition.ts +0 -96
  26. package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
  27. package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
  28. package/src/agent/agent-log/agent-log.tsx +0 -308
  29. package/src/agent/agent-models/agent-models.ts +0 -166
  30. package/src/agent/agent-models/agent-models.types.ts +0 -24
  31. package/src/agent/agent-profile/agent-profile.tsx +0 -1006
  32. package/src/agent/agent-state/agent-state.ts +0 -85
  33. package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
  34. package/src/agent/agent.tsx +0 -507
  35. package/src/board/board-calendar/board-calendar.tsx +0 -89
  36. package/src/board/board-card/board-card.tsx +0 -106
  37. package/src/board/board-data/board-data.ts +0 -241
  38. package/src/board/board-data/board-data.types.ts +0 -63
  39. package/src/board/board-detail/board-detail.tsx +0 -629
  40. package/src/board/board-gantt/board-gantt.ts +0 -545
  41. package/src/board/board-gantt/board-gantt.tsx +0 -286
  42. package/src/board/board-graph/board-graph.ts +0 -174
  43. package/src/board/board-graph/board-graph.tsx +0 -168
  44. package/src/board/board-items/board-items.ts +0 -183
  45. package/src/board/board-kanban/board-kanban.ts +0 -137
  46. package/src/board/board-kanban/board-kanban.tsx +0 -257
  47. package/src/board/board-status/board-status.ts +0 -59
  48. package/src/board/board-statuses/board-statuses.ts +0 -63
  49. package/src/board/board-statuses/board-statuses.tsx +0 -228
  50. package/src/board/board-table/board-table.ts +0 -33
  51. package/src/board/board-table/board-table.tsx +0 -413
  52. package/src/board/board-views/board-views.tsx +0 -68
  53. package/src/board/board-views/board-views.types.ts +0 -29
  54. package/src/board/board.tsx +0 -251
  55. package/src/components/ui/item-calendar.tsx +0 -181
  56. package/src/components/ui/item-gantt.tsx +0 -463
  57. package/src/components/ui/kanban.tsx +0 -282
  58. package/src/data/agent-runtime/agent-runtime.ts +0 -131
  59. package/src/hooks/use-capabilities.ts +0 -22
  60. package/src/hooks/use-model-catalog.ts +0 -26
  61. package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
  62. package/src/timezone/timezone-context.tsx +0 -65
  63. package/src/timezone/timezone.ts +0 -174
@@ -1,160 +0,0 @@
1
- import type { AgentDefinition } from "@anchrd/intel-contract";
2
- import { CalendarClock } from "lucide-react";
3
- import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
4
- import { useI18n } from "@/i18n/i18n-context.tsx";
5
- import { SectionHint } from "@/section-hint/section-hint.tsx";
6
- import { useChosenTimezone } from "@/timezone/timezone-context.tsx";
7
-
8
- // How far ahead one schedule is shown. Enough to recognise a rhythm — daily, weekly, the first of
9
- // the month — and not so much that the page becomes a year planner nobody asked for.
10
- const PerSchedule = 5;
11
-
12
- /**
13
- * What this agent is going to do, and when.
14
- *
15
- * ⚠️ Derived, never asked. The runtime arms one alarm at a time and publishes neither it nor a
16
- * calendar, so these times are this page's own reading of the same cron expressions — by the same
17
- * rules, pinned by `agent-cron.unit.ts` against the runtime's test.
18
- *
19
- * ⚠️ Each entry is drawn in ITS OWN schedule's timezone and names it (#228). Not in the reader's:
20
- * the agent fires at the hour its schedule says, and an instant shown in the zone of whoever is
21
- * looking would be a different hour from the one the agent keeps — a difference that only ever
22
- * surfaces as "it ran at the wrong time". And not one zone for the whole list either, because two
23
- * schedules of one agent may sit in different ones.
24
- *
25
- * ⚠️ The reader's own reading is ADDED beside it, never in its place (#256). The schedule's time is
26
- * the leading one because it is the reason the agent runs; the second is a reading aid, smaller and
27
- * behind it. A calendar that converted everything to the reader would have undone #228 with a
28
- * feature request.
29
- *
30
- * ⚠️ And only for a zone somebody SET. `useChosenTimezone` answers `null` where nothing was chosen,
31
- * and then this screen is exactly what it was — no browser guess dressed up as "your time", and no
32
- * second time on every row for a reader who never asked for one.
33
- *
34
- * ⚠️ A paused agent gets a sentence over the list, because this is the tab somebody opens to find
35
- * out WHETHER something runs (#203). Without it two places on one page said opposite things: the
36
- * header read `Paused · no schedule fires`, and this list went on naming the next five times as if
37
- * they would happen. The list is kept rather than emptied — what the agent WOULD do is still the
38
- * answer to "what did I switch off" — but it may not read as a plan.
39
- */
40
- export function AgentCalendar({
41
- definition,
42
- now,
43
- paused,
44
- }: {
45
- definition: AgentDefinition | null;
46
- // Handed in rather than read from the clock, so the view is a function of its input and a test
47
- // does not have to travel in time to assert on it.
48
- now: Date;
49
- // ⚠️ Handed in for the same reason, and not read from `useAgentState` here: that hook needs the
50
- // router context and a query client, and reaching for it would turn every test of this view into
51
- // an integration test of two providers. The caller already holds the state for the header.
52
- paused: boolean;
53
- }) {
54
- const i18n = useI18n();
55
- const mine = useChosenTimezone();
56
- const schedules = definition?.schedules ?? [];
57
- const upcoming = schedules
58
- // ⚠️ The schedule's own position rides along, and it is the only thing that separates two
59
- // LITERALLY identical entries (#323). Two schedules with the same cron, zone and target are a
60
- // definition somebody can save, and every other part of the key would be equal for them —
61
- // including the fire time, which is computed from exactly those three.
62
- .flatMap((schedule, position) =>
63
- nextCronFires(schedule.cron, now, PerSchedule, schedule.timezone).map((at) => ({
64
- at,
65
- schedule,
66
- position,
67
- })),
68
- )
69
- .sort((left, right) => left.at.getTime() - right.at.getTime());
70
-
71
- // One formatter per zone rather than one per row: a DateTimeFormat is expensive to build, and a
72
- // calendar of five entries per schedule would otherwise build one for each of them.
73
- const formatters = new Map<string, Intl.DateTimeFormat>();
74
- const formatIn = (zone: string, at: Date) => {
75
- let format = formatters.get(zone);
76
- if (!format) {
77
- format = new Intl.DateTimeFormat(i18n.locale, {
78
- dateStyle: "full",
79
- timeStyle: "short",
80
- timeZone: zone,
81
- });
82
- formatters.set(zone, format);
83
- }
84
- return format.format(at);
85
- };
86
-
87
- // Shorter than the leading time on purpose: it is a reading aid, not a second headline. The date
88
- // rides along because a schedule in Tokyo is regularly the reader's previous day.
89
- const myFormat =
90
- mine === null
91
- ? null
92
- : new Intl.DateTimeFormat(i18n.locale, {
93
- dateStyle: "medium",
94
- timeStyle: "short",
95
- timeZone: mine,
96
- });
97
-
98
- return (
99
- <section
100
- aria-label={i18n.t("agent.calendar")}
101
- className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
102
- >
103
- {/* ⚠️ Behind the ⓘ rather than as a paragraph (#256): it was the longest text on the tab and
104
- it explained, on every visit, a rule the list itself shows. The heading is what the hint
105
- now hangs off — the same shape the profile's sections have. */}
106
- <div className="flex items-center gap-1.5">
107
- <h3 className="text-sm font-semibold">{i18n.t("agent.calendar")}</h3>
108
- <SectionHint hint={i18n.t("agent.calendarZones")} />
109
- </div>
110
- {/* ⚠️ An agent with no schedule shows an empty calendar and nothing else (#254). The sentence
111
- that used to stand here described the emptiness the reader is already looking at; the two
112
- other states below are different, because each of them is a fact the list cannot show. */}
113
- {/* ⚠️ Only where there is a list to qualify. A paused agent WITHOUT schedules shows the same
114
- empty calendar it always did — the two states have to stay distinguishable, and a sentence
115
- about times that do not happen, over no times at all, describes nothing. Same reason it
116
- does not appear over `calendarUnreadable`: that one already says nothing is planned. */}
117
- {paused && upcoming.length > 0 ? (
118
- <p role="status" className="mt-4 rounded-lg border bg-muted px-4 py-3 text-sm">
119
- {i18n.t("agent.calendarPaused")}
120
- </p>
121
- ) : null}
122
- {schedules.length === 0 ? null : upcoming.length === 0 ? (
123
- // Schedules exist and none of them fires: every expression is unusable. A different answer
124
- // from "no schedules", because it is a different problem and needs a different fix.
125
- <p role="alert" className="mt-4 text-sm text-destructive">
126
- {i18n.t("agent.calendarUnreadable")}
127
- </p>
128
- ) : (
129
- <ol className="mt-4 space-y-2">
130
- {upcoming.map((entry) => (
131
- <li
132
- key={`${entry.position}:${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
133
- className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
134
- >
135
- <CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
136
- <time dateTime={entry.at.toISOString()} className="font-medium">
137
- {formatIn(entry.schedule.timezone, entry.at)}
138
- </time>
139
- {/* The zone stands next to the time, not once above the list: without it the same
140
- wall-clock hour from two schedules reads as a duplicate. */}
141
- <span className="text-xs text-muted-foreground">{entry.schedule.timezone}</span>
142
- {/* ⚠️ Nothing at all where the two zones agree (#256). For the ordinary reader — one
143
- zone, their own — the row must not grow a second time saying what the first
144
- already says. */}
145
- {myFormat && mine !== entry.schedule.timezone ? (
146
- <span className="text-xs text-muted-foreground">
147
- {i18n.t("agent.calendarYourTime", { when: myFormat.format(entry.at) })}
148
- </span>
149
- ) : null}
150
- <code className="rounded bg-muted px-2 py-0.5 text-xs">{entry.schedule.cron}</code>
151
- <span className="text-xs text-muted-foreground">
152
- {i18n.t(`node.kind.${entry.schedule.target.kind}`)}
153
- </span>
154
- </li>
155
- ))}
156
- </ol>
157
- )}
158
- </section>
159
- );
160
- }
@@ -1,118 +0,0 @@
1
- import {
2
- AssistantRuntimeProvider,
3
- ComposerPrimitive,
4
- ErrorPrimitive,
5
- MessagePrimitive,
6
- ThreadPrimitive,
7
- } from "@assistant-ui/react";
8
- import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
9
- import { ArrowDown, Send } from "lucide-react";
10
- import { useMemo } from "react";
11
- import { useI18n } from "@/i18n/i18n-context.tsx";
12
- import { useIntelRouterContext } from "@/router/router-context.ts";
13
-
14
- /**
15
- * The built-in chat, against the agent runtime's own SSE endpoint.
16
- *
17
- * ⚠️ assistant-ui rather than a hand-rolled reader, and the reason is the wire: the runtime already
18
- * frames its answer as a Vercel AI UI message stream (`x-vercel-ai-ui-message-stream: v1`) with
19
- * `text-delta`, `tool-input-available` and `tool-output-available` parts. That format has a client,
20
- * and writing a second one would mean re-implementing tool-call rendering and stream recovery to
21
- * arrive at the same place.
22
- *
23
- * ⚠️ The endpoint is on INTEL, which forwards the turn to the runtime with the caller's own Gate
24
- * token (#178). That is what `credentials: "include"` is carrying: the intel session cookie, on the
25
- * origin this page came from. The runtime still demands `agents/run` — it just hears it from intel
26
- * rather than from a bearer this page would have had to hold.
27
- */
28
- export function AgentChat({ agentId }: { agentId: string }) {
29
- const { data } = useIntelRouterContext();
30
- const i18n = useI18n();
31
- // ⚠️ Built once per agent. A transport rebuilt on every render restarts the runtime underneath
32
- // assistant-ui and takes the open thread with it.
33
- const transport = useMemo(
34
- () =>
35
- new AssistantChatTransport({
36
- api: data.agentChatApi(agentId),
37
- credentials: "include",
38
- }),
39
- [data, agentId],
40
- );
41
- const runtime = useChatRuntime({ transport });
42
-
43
- return (
44
- <AssistantRuntimeProvider runtime={runtime}>
45
- <ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col bg-card">
46
- <ThreadPrimitive.Viewport
47
- autoScroll
48
- className="relative flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5"
49
- >
50
- <ThreadPrimitive.Empty>
51
- <p className="m-auto max-w-md text-center text-sm text-muted-foreground">
52
- {i18n.t("agent.chatEmpty")}
53
- </p>
54
- </ThreadPrimitive.Empty>
55
- <ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
56
- <ThreadPrimitive.ScrollToBottom asChild>
57
- <button
58
- type="button"
59
- aria-label={i18n.t("agent.chatScrollDown")}
60
- className="sticky bottom-0 z-10 mx-auto inline-flex size-8 items-center justify-center rounded-full border bg-background shadow-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:invisible"
61
- >
62
- <ArrowDown aria-hidden="true" className="size-4" />
63
- </button>
64
- </ThreadPrimitive.ScrollToBottom>
65
- </ThreadPrimitive.Viewport>
66
- <ComposerPrimitive.Root className="flex items-end gap-2 border-t px-6 py-4">
67
- <ComposerPrimitive.Input
68
- rows={1}
69
- autoFocus
70
- aria-label={i18n.t("agent.chatInput")}
71
- placeholder={i18n.t("agent.chatPlaceholder")}
72
- className="max-h-40 min-h-9 flex-1 resize-none rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
73
- />
74
- <ComposerPrimitive.Send asChild>
75
- <button
76
- type="button"
77
- aria-label={i18n.t("agent.chatSend")}
78
- className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
79
- >
80
- <Send aria-hidden="true" className="size-4" />
81
- </button>
82
- </ComposerPrimitive.Send>
83
- </ComposerPrimitive.Root>
84
- </ThreadPrimitive.Root>
85
- </AssistantRuntimeProvider>
86
- );
87
- }
88
-
89
- function UserMessage() {
90
- return (
91
- <MessagePrimitive.Root className="flex justify-end">
92
- <div className="max-w-[80%] rounded-2xl bg-primary px-4 py-2 text-sm text-primary-foreground">
93
- <MessagePrimitive.Parts />
94
- </div>
95
- </MessagePrimitive.Root>
96
- );
97
- }
98
-
99
- function AssistantMessage() {
100
- return (
101
- <MessagePrimitive.Root className="flex justify-start">
102
- <div className="max-w-[80%] space-y-2 text-sm">
103
- <MessagePrimitive.Parts />
104
- {/* The runtime reports a failed turn as an `error` part on a 200 stream, so a refusal shows
105
- up here rather than as a request that failed. It is worded by the runtime, which words
106
- it to be read (see `packages/agent`'s guard on model-facing errors). */}
107
- <MessagePrimitive.Error>
108
- <ErrorPrimitive.Root
109
- role="alert"
110
- className="rounded-md border border-destructive/40 px-3 py-2 text-destructive"
111
- >
112
- <ErrorPrimitive.Message />
113
- </ErrorPrimitive.Root>
114
- </MessagePrimitive.Error>
115
- </div>
116
- </MessagePrimitive.Root>
117
- );
118
- }
@@ -1,95 +0,0 @@
1
- import type { AgentCosts, AgentCostWindow } from "@anchrd/intel-contract";
2
- import { type UseQueryResult, useQuery } from "@tanstack/react-query";
3
- import type { AgentRun, AgentUsage } from "@/data/agent-runtime/agent-runtime.ts";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
5
-
6
- /**
7
- * What this agent has cost, out of Cloudflare's AI Gateway log (#251).
8
- *
9
- * ⚠️ `retry: false`, and the run list beside it keeps its retries. These figures ENRICH a screen
10
- * that is already answered — the runs and their tokens come from the runtime and never depended on
11
- * Cloudflare — and a retry is paused while the tab is unfocused (#212), so a list that waited on
12
- * this one could sit on "Loading" for as long as the reader is looking elsewhere.
13
- */
14
- export function useAgentCosts(agentId: string): UseQueryResult<AgentCosts> {
15
- const { data } = useIntelRouterContext();
16
- return useQuery({
17
- queryKey: ["agent-costs", agentId],
18
- queryFn: () => data.getAgentCosts(agentId),
19
- retry: false,
20
- });
21
- }
22
-
23
- export function costWindow(costs: AgentCosts | undefined, days: number): AgentCostWindow | null {
24
- return costs?.windows.find((window) => window.days === days) ?? null;
25
- }
26
-
27
- /**
28
- * What one run cost, or why that is not known.
29
- *
30
- * ⚠️ Four outcomes and not two, because "nothing" and "not known" are different answers and only one
31
- * of them is about money. A gateway that was never configured, a gateway that did not answer, and a
32
- * run the gateway has no line for all mean "not known" — and rendering any of them as `$0.00` says
33
- * the agent was free, which is the failure this ticket exists to remove.
34
- */
35
- export type RunCost =
36
- | { kind: "known"; cost: number; calls: number }
37
- | { kind: "not_configured" }
38
- | { kind: "unreadable" }
39
- | { kind: "missing" };
40
-
41
- export function runCost(costs: AgentCosts | undefined, runId: string): RunCost {
42
- if (!costs || costs.status === "unreadable") return { kind: "unreadable" };
43
- if (costs.status === "not_configured") return { kind: "not_configured" };
44
- const found = costs.runs.find((run) => run.runId === runId);
45
- // ⚠️ A run with no line is `missing`, never zero. Every run before #251 is one, and so is any run
46
- // whose window has scrolled past the read's page limit.
47
- return found ? { kind: "known", cost: found.cost, calls: found.calls } : { kind: "missing" };
48
- }
49
-
50
- /**
51
- * What one run consumed, or why that is not known either.
52
- *
53
- * ⚠️ All four counters at zero is read as "the provider reported nothing", NOT as "no tokens". A
54
- * completed model call always consumed input tokens, so four zeroes cannot honestly mean zero — and
55
- * Workers AI reports no usage at all, so every Workers AI run looks exactly like this. Showing them
56
- * as `0 in, 0 out` would put a false and very reassuring number on the screen.
57
- */
58
- export function runUsage(run: AgentRun): AgentUsage | null {
59
- const usage = run.usage;
60
- if (!usage) return null;
61
- const total =
62
- usage.inputTokens +
63
- usage.outputTokens +
64
- usage.cacheCreationInputTokens +
65
- usage.cacheReadInputTokens;
66
- return total > 0 ? usage : null;
67
- }
68
-
69
- /**
70
- * Money, with enough decimals to still be money.
71
- *
72
- * ⚠️ Four decimals below a dollar, not two. A single model call costs $0.0565 — at two decimals it
73
- * is "$0.06", and two runs that differ by half again read identically. Above a dollar the extra
74
- * digits are noise and are dropped.
75
- *
76
- * ⚠️ A cost that is real but smaller than the smallest digit shown gets a "<", never a rounded
77
- * "$0.00". The whole point of the figure is that nothing is silently free.
78
- */
79
- export function formatCost(dollars: number, locale: string): string {
80
- const digits = Math.abs(dollars) >= 1 ? 2 : 4;
81
- const format = (value: number) =>
82
- value.toLocaleString(locale, {
83
- style: "currency",
84
- currency: "USD",
85
- minimumFractionDigits: 2,
86
- maximumFractionDigits: digits,
87
- });
88
- if (dollars > 0 && dollars < 0.0001) return `< ${format(0.0001)}`;
89
- return format(dollars);
90
- }
91
-
92
- /** Token counts, short. 56 473 is "56.5K" — the exact figure helps nobody and costs the eye a line. */
93
- export function formatTokens(tokens: number, locale: string): string {
94
- return tokens.toLocaleString(locale, { notation: "compact", maximumFractionDigits: 1 });
95
- }
@@ -1,68 +0,0 @@
1
- import { Cron } from "croner";
2
-
3
- /**
4
- * When a five-field cron expression fires next, read in the schedule's own timezone.
5
- *
6
- * ⚠️ This is still a second copy of `packages/agent/src/schedule/cron/cron.ts` — the runtime imports
7
- * nothing from the contract and this package cannot import a Worker package, so there remains no
8
- * third place for it. What changed with #228 is WHAT is duplicated: both files are now a thin
9
- * wrapper over croner, and the part that could actually disagree — the matching, DST, the day-field
10
- * OR rule — is one implementation in the library.
11
- *
12
- * That was the real risk. The old comment put it plainly: "a calendar that promises 08:00 while the
13
- * alarm fires at 09:00 is worse than a calendar that says nothing" — and two independent
14
- * reimplementations of timezone arithmetic is exactly how that happens.
15
- */
16
-
17
- // One year is the search bound. An expression that matches nothing within a year — "31st of
18
- // February" — has no next fire time, and saying so beats stepping forever.
19
- const MaxYearsAhead = 1;
20
-
21
- function schedule(expression: string, timezone: string): Cron | null {
22
- try {
23
- const job = new Cron(expression.trim(), { timezone, paused: true });
24
- // croner also accepts six fields (with seconds). Intel's contract is five, and so is the hint
25
- // shown next to the input — a six-field expression must not quietly mean something else.
26
- return expression.trim().split(/\s+/).length === 5 ? job : null;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- // ⚠️ The croner call is inside the try, not only the construction. An unknown timezone can surface
33
- // either place depending on when croner first converts a date — and an exception escaping here ends
34
- // up inside a Durable Object alarm, which is the one place nobody is watching.
35
- function runs(expression: string, after: Date, count: number, timezone: string): Date[] {
36
- const job = schedule(expression, timezone);
37
- if (!job) return [];
38
- try {
39
- return job.nextRuns(count, after);
40
- } catch {
41
- return [];
42
- }
43
- }
44
-
45
- export function nextCronFire(expression: string, after: Date, timezone: string): Date | null {
46
- // Strictly after: a schedule that just fired must not match the same minute again. croner's
47
- // `nextRuns(…, from)` is already exclusive of `from`.
48
- const next = runs(expression, after, 1, timezone)[0];
49
- if (!next) return null;
50
- const limit = new Date(after.getTime());
51
- limit.setUTCFullYear(limit.getUTCFullYear() + MaxYearsAhead);
52
- return next > limit ? null : next;
53
- }
54
-
55
- /**
56
- * The next `count` fire times of one expression.
57
- *
58
- * An unparseable or never-matching expression yields an empty list — the caller says "nothing"
59
- * rather than guessing.
60
- */
61
- export function nextCronFires(
62
- expression: string,
63
- after: Date,
64
- count: number,
65
- timezone: string,
66
- ): Date[] {
67
- return runs(expression, after, count, timezone);
68
- }
@@ -1,96 +0,0 @@
1
- import type { AgentDefinition, AgentDefinitionInput, NodeAgent } from "@anchrd/intel-contract";
2
- import {
3
- type UseMutationResult,
4
- type UseQueryResult,
5
- useMutation,
6
- useQuery,
7
- useQueryClient,
8
- } from "@tanstack/react-query";
9
- import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
10
- import { useIntelRouterContext } from "@/router/router-context.ts";
11
-
12
- export const agentKey = (nodeId: string) => ["agent", nodeId] as const;
13
-
14
- export interface AgentDefinitionHandle {
15
- query: UseQueryResult<NodeAgent>;
16
- definition: AgentDefinition | null;
17
- save: UseMutationResult<NodeAgent, Error, AgentDefinitionInput>;
18
- }
19
-
20
- /**
21
- * The definition as it was read, turned back into something this screen may ASK for (D30).
22
- *
23
- * ⚠️ It exists to drop exactly one field: `tools.delegatedBy`. Whose portal connection an agent
24
- * acts on is Intel's to write, out of the session, and the input shape has no place for it — so a
25
- * section that edits schedules must not carry it along just because it was in what it read. Every
26
- * section spreads this rather than the definition itself, which is why none of them has to invent a
27
- * user id for an agent that has no tools yet.
28
- */
29
- export function asDraft(definition: AgentDefinition): AgentDefinitionInput {
30
- return {
31
- references: definition.references,
32
- schedules: definition.schedules,
33
- model: definition.model,
34
- tools: definition.tools === null ? null : { servers: definition.tools.servers },
35
- };
36
- }
37
-
38
- /**
39
- * One agent's definition, and the one way to change it.
40
- *
41
- * ⚠️ Every edit writes a whole new definition version — there is no field-level update, by design
42
- * (ADR-0005): a definition is one immutable document, and what a run read is meant to stay
43
- * readable afterwards. So each section on the profile hands over the definition it wants next,
44
- * whole, and the version it was read from travels as `baseVersionId`. Somebody else's parallel
45
- * change is then a refusal instead of a silent overwrite.
46
- */
47
- export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
48
- const { data } = useIntelRouterContext();
49
- const queryClient = useQueryClient();
50
- const query = useQuery({ queryKey: agentKey(nodeId), queryFn: () => data.getAgent(nodeId) });
51
- const save = useMutation({
52
- mutationFn: async (definition: AgentDefinitionInput) =>
53
- await data.saveAgentDefinition({
54
- nodeId,
55
- baseVersionId: query.data?.version?.id ?? null,
56
- definition,
57
- idempotencyKey: crypto.randomUUID(),
58
- }),
59
- // The answer carries the new version, so the cache is set from it rather than refetched: the
60
- // next save needs that id as its `baseVersionId`, and a round trip would leave a window in
61
- // which it is still the old one.
62
- onSuccess: (saved) => queryClient.setQueryData(agentKey(nodeId), saved),
63
- // ⚠️ A failed save may still have MOVED the version (#214): `agent_schedules_not_armed` means
64
- // the definition was written and only the runtime's alarm was not set, and the repair it names
65
- // is saving again. With the old `baseVersionId` still cached that second save would answer 409
66
- // instead — so the version this screen writes against is refetched whenever a save fails.
67
- onError: () => queryClient.invalidateQueries({ queryKey: agentKey(nodeId) }),
68
- });
69
- return { query, definition: query.data?.definition ?? null, save };
70
- }
71
-
72
- /**
73
- * When this agent next does something on its own, across all of its schedules — and in which zone
74
- * that time is meant.
75
- *
76
- * ⚠️ Derived from the cron expressions, never asked. The runtime arms one alarm at a time and does
77
- * not publish it, so this is a reading of the same expressions by the same rules — since #228 both
78
- * sides read them through croner, so the reading and the alarm cannot drift apart.
79
- *
80
- * ⚠️ The zone travels WITH the time. Two schedules of one agent may sit in different zones, so a
81
- * single "all times are X" line above the screen would be a lie for one of them; the caller formats
82
- * each instant in the zone it came back with.
83
- */
84
- export function nextRunAt(
85
- definition: AgentDefinition | null,
86
- now: Date,
87
- ): { at: Date; timezone: string } | null {
88
- const fires = (definition?.schedules ?? []).flatMap((schedule) => {
89
- const at = nextCronFires(schedule.cron, now, 1, schedule.timezone)[0];
90
- return at ? [{ at, timezone: schedule.timezone }] : [];
91
- });
92
- return fires.reduce<{ at: Date; timezone: string } | null>(
93
- (earliest, fire) => (earliest === null || fire.at < earliest.at ? fire : earliest),
94
- null,
95
- );
96
- }
@@ -1,48 +0,0 @@
1
- import type { I18n } from "@/i18n/i18n.types.ts";
2
-
3
- export interface DelegationReading {
4
- /** Whose portal connection the agent's tools run on — `null` while nothing is delegated. */
5
- delegatedBy: string | null;
6
- /** The signed-in person, or `null` while the session has not been read yet. */
7
- viewerId: string | null;
8
- /** The delegator's display name, or `null` where this browser cannot resolve it (#261). */
9
- delegatorName: string | null;
10
- }
11
-
12
- /**
13
- * Whose connection the tools run on, said so that it stays true for whoever is reading (#258).
14
- *
15
- * ⚠️ "Anyone who may run this agent acts on YOUR connection" was the whole sentence until #258, and
16
- * it is wrong for everybody except the delegator — on the one line in the product where somebody
17
- * learns whose access is being spent. It survives only where it is the strongest true form:
18
- *
19
- * nothing delegated nobody has given anything away yet, and whoever saves the tool selection
20
- * next becomes the delegator. For the reader, that is them.
21
- * the reader's own they ARE the delegator, so "your connection" says it harder than their name
22
- * would. Two readings rather than one are worth it for exactly this: the
23
- * sentence is about a consequence for the reader, and "your" is the word that
24
- * carries a consequence.
25
- * somebody else's the name. And where the name cannot be resolved — which is most ids today,
26
- * because Intel has no user directory (#261) — a sentence that names nobody,
27
- * never an invented name and never silence. WHOSE access is spent may be
28
- * unknown; THAT somebody else's is spent must not be.
29
- *
30
- * ⚠️ One catalog key per reading, each a whole sentence with the name as a parameter. A sentence
31
- * glued together from "acts on" + name + "'s connection" bakes an English possessive into the code,
32
- * and no other language would get to put it anywhere else.
33
- *
34
- * ⚠️ An empty `delegatedBy` reads as "somebody else's", not as "yours". The runtime has that case
35
- * for an archived agent and a definition it could not read (`packages/api/src/tools/tools.ts:39`),
36
- * and it is the one case where being wrong in the reader's favour is worst.
37
- */
38
- export function delegationNotice(i18n: I18n, reading: DelegationReading): string {
39
- const { delegatedBy, viewerId, delegatorName } = reading;
40
- if (delegatedBy === null) return i18n.t("agent.toolsDelegationNotice");
41
- if (delegatedBy.length > 0 && delegatedBy === viewerId) {
42
- return i18n.t("agent.toolsDelegationNotice");
43
- }
44
- if (delegatorName !== null) {
45
- return i18n.t("agent.toolsDelegationNoticeBy", { name: delegatorName });
46
- }
47
- return i18n.t("agent.toolsDelegationNoticeSomebody");
48
- }
@@ -1,72 +0,0 @@
1
- import type { NodeKind } from "@anchrd/intel-contract";
2
- import { useQuery } from "@tanstack/react-query";
3
- import { useI18n } from "@/i18n/i18n-context.tsx";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
5
-
6
- export interface EntryTitle {
7
- title: string;
8
- known: boolean;
9
- /** The kind of node this is, or `null` for a flow and for anything not available to the reader. */
10
- kind: NodeKind | null;
11
- /**
12
- * The folders it is filed in, outermost first — empty at the top of the tree (#255).
13
- *
14
- * ⚠️ Only the ancestors the reader may see. The walk stops at the first one that is missing from
15
- * the graph, so a partial path is possible and is the honest answer: the graph holds exactly what
16
- * this reader may know about, and inventing a step would name a folder to somebody who may not
17
- * know it exists (#41).
18
- */
19
- path: string[];
20
- }
21
-
22
- /**
23
- * The name behind an id, read from the lists the tree already loads.
24
- *
25
- * ⚠️ An entry the reader may not see is absent from both lists, and this then answers "not
26
- * available to you" rather than a raw id or an invented title. Deliberately the same answer a
27
- * reference to something deleted gets: the two must not be distinguishable, or the row would
28
- * confirm that a document exists to somebody who may not know it (#41).
29
- *
30
- * Its own module because two screens need it — the profile's references and schedules, and the
31
- * "run now" menu in the page head, which has to name the targets it offers.
32
- */
33
- export function useEntryTitle(entryId: string, flow = false): EntryTitle {
34
- const { data } = useIntelRouterContext();
35
- const i18n = useI18n();
36
- const graph = useQuery({
37
- queryKey: ["node-graph"],
38
- queryFn: () => data.getNodeGraph(),
39
- enabled: !flow,
40
- });
41
- const flows = useQuery({ queryKey: ["flows"], queryFn: () => data.listFlows(), enabled: flow });
42
- if (flow) {
43
- const found = flows.data?.items.find((item) => item.id === entryId);
44
- if (found) return { title: found.title, known: true, kind: null, path: [] };
45
- return { ...unknown(i18n, flows.isPending), kind: null };
46
- }
47
-
48
- const nodes = graph.data?.nodes ?? [];
49
- const found = nodes.find((node) => node.id === entryId);
50
- if (!found) return { ...unknown(i18n, graph.isPending), kind: null };
51
-
52
- // Upwards from the node, then reversed: outermost folder first, which is how a path reads.
53
- const path: string[] = [];
54
- const seen = new Set<string>([found.id]);
55
- let parentId = found.parentId;
56
- while (parentId !== null && !seen.has(parentId)) {
57
- seen.add(parentId);
58
- const parent = nodes.find((node) => node.id === parentId);
59
- if (!parent) break;
60
- path.unshift(parent.title);
61
- parentId = parent.parentId;
62
- }
63
- return { title: found.title, known: true, kind: found.kind, path };
64
- }
65
-
66
- function unknown(i18n: ReturnType<typeof useI18n>, pending: boolean) {
67
- return {
68
- title: i18n.t(pending ? "common.loading" : "agent.unknownEntry"),
69
- known: false,
70
- path: [] as string[],
71
- };
72
- }