@intentic/sandbox-contract 1.301.0 → 1.303.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.
- package/dist/contracts/agent.contract.d.ts +6 -9
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +193 -433
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +6 -24
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/areas.contract.d.ts +24 -0
- package/dist/contracts/areas.contract.d.ts.map +1 -0
- package/dist/contracts/areas.contract.js +32 -0
- package/dist/contracts/areas.contract.js.map +1 -0
- package/dist/contracts/automations.contract.d.ts +3 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +0 -46
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.d.ts +132 -0
- package/dist/contracts/endpoints.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.js +50 -0
- package/dist/contracts/endpoints.contract.js.map +1 -1
- package/dist/contracts/providers.contract.d.ts +21 -0
- package/dist/contracts/providers.contract.d.ts.map +1 -1
- package/dist/contracts/providers.contract.js +1 -0
- package/dist/contracts/providers.contract.js.map +1 -1
- package/dist/contracts/runner.contract.d.ts +98 -95
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +0 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +110 -6
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.js +19 -1
- package/dist/contracts/settings.contract.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +23 -12
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts +1 -3
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/agent-events.js +1 -0
- package/dist/events/agent-events.js.map +1 -1
- package/dist/events/system-events.d.ts +32 -6
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/transcript.d.ts +2 -6
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/events/transcript.js +5 -1
- package/dist/events/transcript.js.map +1 -1
- package/dist/index.d.ts +481 -549
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/models/agent-catalog.d.ts.map +1 -1
- package/dist/models/agent-catalog.js +1 -1
- package/dist/models/agent-catalog.js.map +1 -1
- package/dist/policy/fence-paths.d.ts +10 -0
- package/dist/policy/fence-paths.d.ts.map +1 -0
- package/dist/policy/fence-paths.js +58 -0
- package/dist/policy/fence-paths.js.map +1 -0
- package/dist/protocol/routes.d.ts.map +1 -1
- package/dist/protocol/routes.js +4 -1
- package/dist/protocol/routes.js.map +1 -1
- package/dist/schemas/agents.d.ts +51 -19
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +7 -22
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/areas.d.ts +21 -0
- package/dist/schemas/areas.d.ts.map +1 -0
- package/dist/schemas/areas.js +32 -0
- package/dist/schemas/areas.js.map +1 -0
- package/dist/schemas/automations.d.ts +19 -3
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +2 -0
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/capabilities.d.ts +11 -46
- package/dist/schemas/capabilities.d.ts.map +1 -1
- package/dist/schemas/capabilities.js +15 -14
- package/dist/schemas/capabilities.js.map +1 -1
- package/dist/schemas/personas.d.ts.map +1 -1
- package/dist/schemas/personas.js +5 -1
- package/dist/schemas/personas.js.map +1 -1
- package/dist/schemas/providers/provider-oauth.d.ts +2 -0
- package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/providers/provider-oauth.js +4 -0
- package/dist/schemas/providers/provider-oauth.js.map +1 -1
- package/dist/schemas/providers/usage.d.ts +4 -0
- package/dist/schemas/providers/usage.d.ts.map +1 -1
- package/dist/schemas/providers/usage.js +4 -0
- package/dist/schemas/providers/usage.js.map +1 -1
- package/dist/schemas/settings.d.ts +99 -3
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +51 -14
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/shared.d.ts +4 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/schemas/shared.js +3 -3
- package/dist/schemas/shared.js.map +1 -1
- package/dist/schemas/turn-break.d.ts +32 -0
- package/dist/schemas/turn-break.d.ts.map +1 -0
- package/dist/schemas/turn-break.js +16 -0
- package/dist/schemas/turn-break.js.map +1 -0
- package/dist/state/definition.d.ts +40 -56
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/history-state.d.ts.map +1 -1
- package/dist/state/history-state.js +2 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/state/workspace-state.d.ts +5 -0
- package/dist/state/workspace-state.d.ts.map +1 -1
- package/dist/state/workspace-state.js +1 -0
- package/dist/state/workspace-state.js.map +1 -1
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +3 -7
- package/dist/text/transcript-fold.js.map +1 -1
- package/dist/time/zone.d.ts +27 -0
- package/dist/time/zone.d.ts.map +1 -0
- package/dist/time/zone.js +35 -0
- package/dist/time/zone.js.map +1 -0
- package/package.json +16 -5
- package/src/contracts/agents.contract.ts +7 -30
- package/src/contracts/areas.contract.ts +42 -0
- package/src/contracts/endpoints.contract.ts +82 -0
- package/src/contracts/providers.contract.ts +5 -0
- package/src/contracts/settings.contract.ts +28 -0
- package/src/events/agent-events.ts +4 -1
- package/src/events/transcript.ts +9 -1
- package/src/index.ts +19 -1
- package/src/models/agent-catalog.ts +1 -1
- package/src/policy/fence-paths.test.ts +114 -0
- package/src/policy/fence-paths.ts +99 -0
- package/src/protocol/routes.test.ts +12 -0
- package/src/protocol/routes.ts +8 -1
- package/src/schemas/agents.ts +14 -35
- package/src/schemas/areas.test.ts +40 -0
- package/src/schemas/areas.ts +49 -0
- package/src/schemas/automations.ts +8 -0
- package/src/schemas/capabilities.ts +37 -19
- package/src/schemas/personas.ts +15 -4
- package/src/schemas/providers/provider-oauth.ts +9 -0
- package/src/schemas/providers/usage.ts +9 -0
- package/src/schemas/settings.ts +107 -26
- package/src/schemas/shared.ts +9 -6
- package/src/schemas/turn-break.ts +49 -0
- package/src/state/history-state.ts +7 -0
- package/src/state/workspace-state.test.ts +7 -3
- package/src/state/workspace-state.ts +9 -2
- package/src/text/transcript-fold.test.ts +3 -5
- package/src/text/transcript-fold.ts +3 -7
- package/src/time/zone.test.ts +90 -0
- package/src/time/zone.ts +118 -0
package/src/schemas/settings.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { z } from "zod";
|
|
|
5
5
|
import { CommandJudgeModeSchema } from "../policy/safety-policy.js";
|
|
6
6
|
import { ModelRoleSchema } from "../models/model-roles.js";
|
|
7
7
|
import { AdmissionPolicySchema, AdmissionRuleSchema, ModelPinSchema } from "./agent.js";
|
|
8
|
+
import { LimitPolicySchema, RetryPolicySchema } from "./turn-break.js";
|
|
9
|
+
import { ZoneSchema } from "../time/zone.js";
|
|
8
10
|
// Which prompt base the agent runs before this turn composes anything on top: Intentic's own (default), Claude Code's
|
|
9
11
|
// preset, or the owner's text. Declared out here since both the daemon and the browser branch on it.
|
|
10
12
|
export const SystemPromptModeSchema = z.enum(["intentic", "claude", "custom"]);
|
|
@@ -202,6 +204,20 @@ export type SkillSwitch = z.infer<typeof SkillSwitchSchema>;
|
|
|
202
204
|
// picks rather than failing whole.
|
|
203
205
|
|
|
204
206
|
export const SandboxSettingsSchema = z.object({
|
|
207
|
+
// The zone every WALL-CLOCK RULE in this sandbox is meant in — an automation's cron, and anything else that says
|
|
208
|
+
// "at 09:00" instead of naming an instant. It is not a display preference: nothing formats through it, and an
|
|
209
|
+
// instant on screen is still drawn in the reader's own clock.
|
|
210
|
+
// Empty means nobody has said, and the container's clock (UTC) answers. That is harmless for a sandbox where
|
|
211
|
+
// nothing is scheduled and wrong the moment something is, which is why the editor offers its own zone on first
|
|
212
|
+
// load and the automations screen names this one beside every schedule.
|
|
213
|
+
// An IANA id rather than an offset, because an offset cannot express the summer-time rule that moves "09:00" twice
|
|
214
|
+
// a year, and a chore set in March should still be right in November.
|
|
215
|
+
timezone: z
|
|
216
|
+
.union([z.literal(""), ZoneSchema])
|
|
217
|
+
.default("")
|
|
218
|
+
.describe(
|
|
219
|
+
"Which clock this sandbox's schedules are set by, as a zone name like Europe/Warsaw. Automations that repeat on a clock fire by this, not by the machine's own time. Leave it empty and they fire by UTC, which is almost certainly not what you meant when you typed a time.",
|
|
220
|
+
),
|
|
205
221
|
stableSystemPrompt: z
|
|
206
222
|
.boolean()
|
|
207
223
|
.default(false)
|
|
@@ -270,6 +286,34 @@ export const SandboxSettingsSchema = z.object({
|
|
|
270
286
|
.describe(
|
|
271
287
|
"What share of conversations to open without the map, so the two can be compared. Whole conversations rather than individual turns, because the map is sent once and stays in the conversation's history afterwards.",
|
|
272
288
|
),
|
|
289
|
+
// The one composed piece that is WRITTEN rather than derived: a monthly automation rewrites it off the session
|
|
290
|
+
// corpus, so it carries what no scan of the tree can (which commands really work here, what the box can take, how
|
|
291
|
+
// the owner asks for things) and the map keeps carrying what a scan can.
|
|
292
|
+
fieldNotes: z
|
|
293
|
+
.boolean()
|
|
294
|
+
.default(false)
|
|
295
|
+
.describe(
|
|
296
|
+
"Open every turn with a brief on how work actually goes in this sandbox: the traps that cost past sessions calls, the commands that really verify, what the machine can take. Written once a month by an automation that reads back the sessions run here, rather than worked out per turn, because it is drawn from history rather than from the tree. Off by default, since it rides every turn of every conversation.",
|
|
297
|
+
),
|
|
298
|
+
// Characters, not sections: the file's own ranking decides WHICH sections, this decides HOW MANY fit. Same unit as
|
|
299
|
+
// the project map's ceiling so the two costs read on one scale.
|
|
300
|
+
fieldNotesBudget: z
|
|
301
|
+
.number()
|
|
302
|
+
.int()
|
|
303
|
+
.min(500)
|
|
304
|
+
.max(20000)
|
|
305
|
+
.default(4000)
|
|
306
|
+
.describe(
|
|
307
|
+
"How much of that brief to send. Its sections are ranked, most costly-to-not-know first, and they are taken whole in that order until this runs out — so raising it buys more of the tail, never a fuller version of the same thing.",
|
|
308
|
+
),
|
|
309
|
+
fieldNotesHoldout: z
|
|
310
|
+
.number()
|
|
311
|
+
.min(0)
|
|
312
|
+
.max(1)
|
|
313
|
+
.default(0)
|
|
314
|
+
.describe(
|
|
315
|
+
"What share of conversations to run without the brief, so the two can be compared. Whole conversations rather than individual turns, because the brief sits in the prompt for the whole session and withholding it from one turn would not take it back.",
|
|
316
|
+
),
|
|
273
317
|
// Only the eager background pass; the `fileq` CLI itself is always on PATH regardless, gated only by its own skill.
|
|
274
318
|
sidecars: z
|
|
275
319
|
.boolean()
|
|
@@ -305,6 +349,15 @@ export const SandboxSettingsSchema = z.object({
|
|
|
305
349
|
.describe(
|
|
306
350
|
"Which models do which job, one ordered list per job: commit messages, session titles, the safety judge, pipeline fixes, and every other place this sandbox picks a model for you. Tried in order, so one spent account does not take a job down. Nothing is chosen for you: a one-shot job with no list does not run, and a whole session with no list opens on whatever your own chat is set to.",
|
|
307
351
|
),
|
|
352
|
+
// Folded into the Auto router's prompt (agent/prompt/model-router.ts), never into a turn's own. Capped small on
|
|
353
|
+
// purpose: the reading runs under a 5s deadline, and this text is paid for on every chat that opens on Auto.
|
|
354
|
+
autoModelGuidance: z
|
|
355
|
+
.string()
|
|
356
|
+
.max(2000)
|
|
357
|
+
.default("")
|
|
358
|
+
.describe(
|
|
359
|
+
"What you would tell somebody choosing the model for a new chat on your behalf: which model you want the cheap work on, which account to leave alone, when to reach for the strongest one. Read once per chat, alongside the models and allowances this sandbox can actually run, and it overrides the product's own advice where the two disagree. It cannot invent a model: the answer is still a choice from that list.",
|
|
360
|
+
),
|
|
308
361
|
// Named by repo id ("root" or discoverRepos's dir); a commit spanning repos gets the trailer only where one was
|
|
309
362
|
// asked for.
|
|
310
363
|
changelogRepos: z
|
|
@@ -322,30 +375,20 @@ export const SandboxSettingsSchema = z.object({
|
|
|
322
375
|
.describe(
|
|
323
376
|
"How many days a finished conversation stays on the board before being put away. Zero means never. The one setting here that defaults on, because each card left behind is a real working copy on disk, not just a row.",
|
|
324
377
|
),
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
.
|
|
338
|
-
|
|
339
|
-
"Whether a turn a spent usage limit refused is sent again by itself once the allowance reopens. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because the allowance is your budget and a turn that spends it the second it comes back is not a decision to make for you. Worth turning on for a sandbox whose work mostly happens with nobody in the room.",
|
|
340
|
-
),
|
|
341
|
-
// Same provider only; a different provider would retire the session for a saving that isn't one. Composes with
|
|
342
|
-
// `resumeAfterLimit` into four postures: hold, wait for reset, move-or-hold, move-or-wait.
|
|
343
|
-
moveAfterLimit: z
|
|
344
|
-
.boolean()
|
|
345
|
-
.default(false)
|
|
346
|
-
.describe(
|
|
347
|
-
"Whether a turn a spent usage limit refused is moved to another connected account of the same provider that still has room, as soon as the refusal lands. The sandbox-wide default; any one conversation can say otherwise. Off to begin with, because it spends a second account on your behalf. With no account that has room the turn waits as the setting above says.",
|
|
348
|
-
),
|
|
378
|
+
// One answer per ending, never a set of switches over the same event: the chat's own question and these rows are
|
|
379
|
+
// the same question at two scopes. Every ending defaults to `wait`, because a re-run spends the reader's allowance
|
|
380
|
+
// on a turn they sent once.
|
|
381
|
+
limitPolicy: LimitPolicySchema.default("wait").describe(
|
|
382
|
+
"What happens to a turn a spent usage limit refused. `wait` holds it for a press. `resend` sends it again by itself once the allowance reopens, which needs a provider that publishes a reset (Grok and Cursor publish none). `move` also tries another connected account of the same provider that still has room, as soon as the refusal lands, and keeps the reset as its fallback. The sandbox-wide default; any one conversation can say otherwise.",
|
|
383
|
+
),
|
|
384
|
+
// Off still records the failure, so the per-conversation offer arms normally; nothing is lost, just not automatic.
|
|
385
|
+
outagePolicy: RetryPolicySchema.default("wait").describe(
|
|
386
|
+
"What happens to a turn the model provider's own failure killed. `wait` holds it for a press. `retry` re-runs it on the shared per-provider breaker, backing off between attempts. The sandbox-wide default; any one conversation can say otherwise. Worth `retry` for a sandbox whose work mostly happens with nobody in the room.",
|
|
387
|
+
),
|
|
388
|
+
// The posture that used to live only in a browser tab; moving it here is what lets it fire with nothing open.
|
|
389
|
+
stopPolicy: RetryPolicySchema.default("wait").describe(
|
|
390
|
+
"What happens to a turn that stopped short with nothing to repair — a hung runtime, a crashed harness. `wait` holds it for a press. `retry` re-runs the held turn on a short ladder, standing down after three tries that got nowhere rather than looping forever.",
|
|
391
|
+
),
|
|
349
392
|
limitMoveCarryUnder: z
|
|
350
393
|
.number()
|
|
351
394
|
.int()
|
|
@@ -360,7 +403,7 @@ export const SandboxSettingsSchema = z.object({
|
|
|
360
403
|
.boolean()
|
|
361
404
|
.default(false)
|
|
362
405
|
.describe(
|
|
363
|
-
"Whether a turn killed by the sandbox restarting is re-run once it comes back.
|
|
406
|
+
"Whether a turn killed by the sandbox restarting is re-run once it comes back. A switch rather than one of the policies above, because a restart is the one ending with nobody watching it, so there is no in-chat question to answer. Off to begin with: it would spend your allowance on work you are not watching and edit files while you are still waiting for the sandbox to return. Either way the interruption is recorded rather than silently lost.",
|
|
364
407
|
),
|
|
365
408
|
// Which repositories' own declarations (`<repo>/.intentic/checks.json`) the owner has switched on, each against the
|
|
366
409
|
// fingerprint of what was declared when they did. A declaration that has since changed no longer matches its
|
|
@@ -421,6 +464,20 @@ export const SandboxSettingsSchema = z.object({
|
|
|
421
464
|
.describe("How many levels deep the delegation may go, since a subagent can start subagents of its own."),
|
|
422
465
|
});
|
|
423
466
|
export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
|
|
467
|
+
|
|
468
|
+
// A browser telling the sandbox which clock IT is on. An offer, not an instruction: see `adoptTimezone`.
|
|
469
|
+
export const TimezoneOfferSchema = z.object({
|
|
470
|
+
timezone: ZoneSchema.describe("The zone the offering machine is in, as an IANA name like Europe/Warsaw."),
|
|
471
|
+
});
|
|
472
|
+
export type TimezoneOffer = z.infer<typeof TimezoneOfferSchema>;
|
|
473
|
+
|
|
474
|
+
// What the sandbox's clock is after the offer, and whether the offer is what set it. `adopted: false` with a zone
|
|
475
|
+
// back means somebody had already chosen, which is the normal answer for every browser after the first.
|
|
476
|
+
export const TimezoneStateSchema = z.object({
|
|
477
|
+
timezone: z.string().describe("The zone this sandbox's schedules are now read in. Empty only if none could be resolved."),
|
|
478
|
+
adopted: z.boolean().describe("Whether this call is what set it. False means it was already answered and the stored zone stands."),
|
|
479
|
+
});
|
|
480
|
+
export type TimezoneState = z.infer<typeof TimezoneStateSchema>;
|
|
424
481
|
// Read live from the installed CLI (preset-prompt.ts), not a stored transcription. `version` is the CLI build it came
|
|
425
482
|
// from, so a fork from an older build reads as a snapshot; empty for Intentic's own prompt.
|
|
426
483
|
export const BuiltinPromptTextSchema = z.object({ text: z.string(), version: z.string() });
|
|
@@ -456,9 +513,10 @@ export const SavingsArmSchema = z.object({ turns: z.number(), mean: z.number() }
|
|
|
456
513
|
// openingSearches: same, narrowed to before the turn first touched a file.
|
|
457
514
|
// openingListings: directory listings a turn ran to orient itself (the project map).
|
|
458
515
|
// callsBeforeTarget: how far a turn walked before touching a file it went on to edit.
|
|
516
|
+
// failedCalls: tool calls that ended in error (the field notes, whose largest section is a failure taxonomy).
|
|
459
517
|
// Never cost: each mechanism moves one small part of a turn's work, inside the noise of the rest.
|
|
460
518
|
export const TurnMetricReadingSchema = z.object({
|
|
461
|
-
metric: z.enum(["searchCalls", "openingSearches", "openingListings", "callsBeforeTarget"]),
|
|
519
|
+
metric: z.enum(["searchCalls", "openingSearches", "openingListings", "callsBeforeTarget", "failedCalls"]),
|
|
462
520
|
on: SavingsArmSchema,
|
|
463
521
|
off: SavingsArmSchema,
|
|
464
522
|
// Additional control turns to reach a fixed target resolution, not today's delta (which inherits noise and always
|
|
@@ -509,11 +567,34 @@ export const DependencySavingsSchema = z.object({
|
|
|
509
567
|
updatedAt: z.number().optional(),
|
|
510
568
|
});
|
|
511
569
|
export type DependencySavings = z.infer<typeof DependencySavingsSchema>;
|
|
570
|
+
// What the settings row can say about the brief without opening it: whether there is one, how much of it this budget
|
|
571
|
+
// reaches, and whether anything is scheduled to rewrite it. Read off the file and the automation store, never stored.
|
|
572
|
+
export const FieldNotesStatusSchema = z.object({
|
|
573
|
+
// False means the automation has never run (or was never set up) — the ordinary state before the first month.
|
|
574
|
+
present: z.boolean(),
|
|
575
|
+
// Epoch ms the file was last written. Absent when there is no file.
|
|
576
|
+
writtenAt: z.number().optional(),
|
|
577
|
+
// How many ranked sections the current budget reaches, out of how many the file holds. The pair is the point: "5"
|
|
578
|
+
// alone cannot tell a generous budget from a short file.
|
|
579
|
+
ranksSent: z.number().optional(),
|
|
580
|
+
ranksTotal: z.number().optional(),
|
|
581
|
+
// What the brief costs the prompt, in characters, at the current budget.
|
|
582
|
+
chars: z.number().optional(),
|
|
583
|
+
// The monthly rewrite: absent until the owner creates it from the offered template, since an automation names the
|
|
584
|
+
// models it spends and nothing chooses those for them.
|
|
585
|
+
automation: z.enum(["missing", "enabled", "disabled"]),
|
|
586
|
+
// When it next runs, epoch ms; absent when there is nothing scheduled.
|
|
587
|
+
nextRunAt: z.number().optional(),
|
|
588
|
+
// Said out loud rather than shown as "no file": a brief that exists and cannot be read is a broken automation.
|
|
589
|
+
unreadable: z.string().optional(),
|
|
590
|
+
});
|
|
591
|
+
export type FieldNotesStatus = z.infer<typeof FieldNotesStatusSchema>;
|
|
512
592
|
export const SavingsReportSchema = z.object({
|
|
513
593
|
input: InputSavingsSchema,
|
|
514
594
|
search: TurnExperimentSchema.optional(),
|
|
515
595
|
// Same absence rule as `search`: not measured, never zero.
|
|
516
596
|
map: TurnExperimentSchema.optional(),
|
|
597
|
+
notes: TurnExperimentSchema.optional(),
|
|
517
598
|
dependencies: DependencySavingsSchema.optional(),
|
|
518
599
|
});
|
|
519
600
|
export type SavingsReport = z.infer<typeof SavingsReportSchema>;
|
package/src/schemas/shared.ts
CHANGED
|
@@ -8,15 +8,18 @@ export const OkSchema = object({
|
|
|
8
8
|
ok: literal(true)
|
|
9
9
|
.describe("Always true. A route that answers this either did the thing or refused with a status; there is no third outcome to report."),
|
|
10
10
|
});
|
|
11
|
-
// Trust tiers, ordered low to high:
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// Trust tiers, ordered low to high: desk talks to the persona cards it was handed and sees nothing else, viewer
|
|
12
|
+
// watches, collaborator's outward actions become requests, writer changes files inside the areas it holds and ships
|
|
13
|
+
// none of them, maintainer holds the owner's authority but is revocable, owner is the one bound identity and not a
|
|
14
|
+
// grant.
|
|
15
|
+
export const MemberRoleSchema = zEnum(["desk", "viewer", "collaborator", "writer", "maintainer", "owner"]);
|
|
14
16
|
export type MemberRole = z.infer<typeof MemberRoleSchema>;
|
|
15
17
|
// Roles an invite can grant: everything but owner, which binds at first sign-in and is never granted.
|
|
16
|
-
export const GrantedRoleSchema = zEnum(["viewer", "collaborator", "maintainer"]);
|
|
18
|
+
export const GrantedRoleSchema = zEnum(["desk", "viewer", "collaborator", "writer", "maintainer"]);
|
|
17
19
|
export type GrantedRole = z.infer<typeof GrantedRoleSchema>;
|
|
18
|
-
// Single source for role order; every surface that gates on a role reads this ranking.
|
|
19
|
-
|
|
20
|
+
// Single source for role order; every surface that gates on a role reads this ranking. A desk is below every
|
|
21
|
+
// floor: its routes are an allowlist of its own (auth/role-floor.ts deskReach), never a floor it can clear.
|
|
22
|
+
const MEMBER_ROLE_RANK: Record<MemberRole, number> = { desk: 0, viewer: 1, collaborator: 2, writer: 3, maintainer: 4, owner: 5 };
|
|
20
23
|
export const roleAtLeast = (role: MemberRole, floor: MemberRole): boolean => MEMBER_ROLE_RANK[role] >= MEMBER_ROLE_RANK[floor];
|
|
21
24
|
// Rotating a door credential (event webhook, release gate, bug intake): the old value stops working immediately; the
|
|
22
25
|
// new one is shown once to be saved.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// turn-break: what happens next when a turn stops before it finished
|
|
2
|
+
//
|
|
3
|
+
// One ending, one question, one answer. A turn stops for exactly one reason, and that reason gets a single
|
|
4
|
+
// mutually-exclusive answer — never a set of switches that can all be armed at once over the same event. The chat
|
|
5
|
+
// asks it about the ending in front of the reader; Settings holds the standing answer per ending; a conversation may
|
|
6
|
+
// override either.
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
// The three endings that leave finished work behind a live session and can be picked back up. Spelled exactly as
|
|
11
|
+
// `TurnEndingSchema.reason` spells them, so the ending a surface reads and the question it asks are one word. A restart
|
|
12
|
+
// is handled apart (SandboxSettings.autoResumeOnRestart): nobody is watching a restart, so it has no in-chat question.
|
|
13
|
+
export const TurnBreakSchema = z.enum(["limit", "outage", "stopped"]);
|
|
14
|
+
export type TurnBreak = z.infer<typeof TurnBreakSchema>;
|
|
15
|
+
|
|
16
|
+
// `move` implies `resend`: an account with room is tried at once, and the reset appointment stands as its fallback.
|
|
17
|
+
// There is deliberately no "move, else hold" — a reader willing to spend a second account is willing to wait.
|
|
18
|
+
export const LimitPolicySchema = z.enum(["wait", "resend", "move"]);
|
|
19
|
+
export type LimitPolicy = z.infer<typeof LimitPolicySchema>;
|
|
20
|
+
|
|
21
|
+
// Both ladders answer the same way, so they share a vocabulary: nothing, or keep trying on a bounded ladder.
|
|
22
|
+
export const RetryPolicySchema = z.enum(["wait", "retry"]);
|
|
23
|
+
export type RetryPolicy = z.infer<typeof RetryPolicySchema>;
|
|
24
|
+
|
|
25
|
+
// One answer, whichever ending asked. Read against the ending: only `limit` can be `move`.
|
|
26
|
+
export const TurnBreakPolicySchema = z.enum(["wait", "resend", "move", "retry"]);
|
|
27
|
+
export type TurnBreakPolicy = z.infer<typeof TurnBreakPolicySchema>;
|
|
28
|
+
|
|
29
|
+
// Which answers an ending may take, so a surface offers exactly these and a writer refuses anything else.
|
|
30
|
+
export const TURN_BREAK_POLICIES: Readonly<Record<TurnBreak, readonly TurnBreakPolicy[]>> = {
|
|
31
|
+
limit: ["wait", "resend", "move"],
|
|
32
|
+
outage: ["wait", "retry"],
|
|
33
|
+
stopped: ["wait", "retry"],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Whether this ending may be answered this way; the one gate every writer goes through. */
|
|
37
|
+
export const isTurnBreakPolicy = (ending: TurnBreak, policy: string): policy is TurnBreakPolicy =>
|
|
38
|
+
(TURN_BREAK_POLICIES[ending] as readonly string[]).includes(policy);
|
|
39
|
+
|
|
40
|
+
/** Whether an answer means something happens without the reader; the one read every "is it armed" question makes. */
|
|
41
|
+
export const breakArmed = (policy: TurnBreakPolicy): boolean => policy !== "wait";
|
|
42
|
+
|
|
43
|
+
// How many times a bounded ladder re-runs a turn that gets nowhere before standing down. Quoted in the notice it
|
|
44
|
+
// leaves, so the number and the sentence cannot drift.
|
|
45
|
+
export const RETRY_LADDER_MS = [5_000, 15_000, 45_000] as const;
|
|
46
|
+
export const RETRY_LADDER_TRIES = RETRY_LADDER_MS.length;
|
|
47
|
+
|
|
48
|
+
/** The wait before the next rung, or undefined once the ladder is spent. */
|
|
49
|
+
export const retryLadderDelay = (triesWithoutProgress: number): number | undefined => RETRY_LADDER_MS[triesWithoutProgress];
|
|
@@ -19,6 +19,10 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
19
19
|
// Armed condition watches; carries safely: the journal holds no credential, only names re-derived on target.
|
|
20
20
|
{ path: "watches/", portability: "carry" },
|
|
21
21
|
{ path: "transcripts/", portability: "carry" },
|
|
22
|
+
// One runtime session store per fenced conversation, holding for it what `.intentic/records/sessions/claude/` holds
|
|
23
|
+
// for every other: its own transcripts, plans, backups and checklists, kept off /work so no other conversation's
|
|
24
|
+
// namespace can reach them. Carried for the same reason that one is.
|
|
25
|
+
{ path: "sessions/", portability: "carry" },
|
|
22
26
|
// What each message can restore to (a checkpoint or isolated commit); the join between transcripts and scopes.
|
|
23
27
|
{ path: "turn-anchors.json", portability: "carry" },
|
|
24
28
|
// Index of published-page conversations; the pages travel with /work anyway, this makes one withdrawable.
|
|
@@ -32,6 +36,9 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
32
36
|
{ path: "provider-refusals.json", portability: "carry" },
|
|
33
37
|
// Models refused to this sandbox's credentials; expires daily, same subscriptions apply elsewhere.
|
|
34
38
|
{ path: "model-refusals.json", portability: "carry" },
|
|
39
|
+
// When a model every credential is benched on reopens. Carried for the same reason as the refusals around it: the
|
|
40
|
+
// wait belongs to the subscription, not to the machine that hit it, and entries past their instant are inert.
|
|
41
|
+
{ path: "model-cooldowns.json", portability: "carry" },
|
|
35
42
|
// What each account has run out of, for a plan publishing no allowance to poll — the refusal is the reading.
|
|
36
43
|
// Carried like the refusals above: an allowance belongs to the account, not to the machine that spent it.
|
|
37
44
|
{ path: "observed-limits.json", portability: "carry" },
|
|
@@ -33,9 +33,10 @@ describe(`staleQueryKeys`, () => {
|
|
|
33
33
|
expect(staleQueryKeys([`.intentic/config/capabilities.json`], [])).toEqual([`capabilities`, `environment`, `panels`, `manifests`]);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it(`refreshes the unreadable-manifest notice for the
|
|
36
|
+
it(`refreshes the unreadable-manifest notice for the four files a person hand-edits`, () => {
|
|
37
37
|
const carries = WORKSPACE_STATE_FILES.filter((file) => file.invalidates.includes(`manifests`)).map((file) => file.path);
|
|
38
38
|
expect(carries.toSorted()).toEqual([
|
|
39
|
+
`.intentic/config/areas.json`,
|
|
39
40
|
`.intentic/config/capabilities.json`,
|
|
40
41
|
`.intentic/config/personas.json`,
|
|
41
42
|
`.intentic/config/settings.json`,
|
|
@@ -316,9 +317,12 @@ describe(`VERSIONED_STATE_PATHS`, () => {
|
|
|
316
317
|
});
|
|
317
318
|
|
|
318
319
|
// Spelled out rather than derived, so adding a tracked entry is a visible edit here, not a silent side effect.
|
|
319
|
-
it(`tracks exactly the configuration
|
|
320
|
+
it(`tracks exactly the configuration files plus the agent's own authored output`, () => {
|
|
320
321
|
expect(VERSIONED_STATE_PATHS.toSorted()).toEqual([
|
|
321
322
|
`${STATE_DIR}/config/approvals/`,
|
|
323
|
+
// Which folders each named area holds: editing one moves what everyone granted it can see, so the
|
|
324
|
+
// change belongs in a diff rather than only in a screen.
|
|
325
|
+
`${STATE_DIR}/config/areas.json`,
|
|
322
326
|
`${STATE_DIR}/config/automations.json`,
|
|
323
327
|
// Which apps the daemon starts at boot: the starter site, plus whatever the owner adds.
|
|
324
328
|
`${STATE_DIR}/config/autostart.json`,
|
|
@@ -493,7 +497,7 @@ describe(`SHARED_STATE_PATHS`, () => {
|
|
|
493
497
|
});
|
|
494
498
|
});
|
|
495
499
|
|
|
496
|
-
// Pins the split: the authored
|
|
500
|
+
// Pins the split: the authored area that now backs up, and the credentials that still must not.
|
|
497
501
|
describe(`BACKED_UP_STATE_PATHS`, () => {
|
|
498
502
|
it(`splits the table in two with nothing falling between`, () => {
|
|
499
503
|
expect([...BACKED_UP_STATE_PATHS, ...UNBACKED_STATE_PATHS].toSorted()).toEqual(WORKSPACE_STATE_FILES.map((file) => file.path).toSorted());
|
|
@@ -72,6 +72,13 @@ const STATE_FILES = [
|
|
|
72
72
|
// capability catalog. `carry`: a card is a name and ids, never a credential.
|
|
73
73
|
{ path: ".intentic/config/personas.json", invalidates: ["personas", "capabilities", "manifests"], portability: "carry", versioned: true },
|
|
74
74
|
|
|
75
|
+
// Named parts of the workspace (AreaSchema), the unit a person's reach is granted in. `carry`: folder names, no
|
|
76
|
+
// credential, and a workspace that moves keeps the shape of its own fences. `versioned` because editing an area
|
|
77
|
+
// changes what people already granted it can see, which is precisely a change a human should review. It stays
|
|
78
|
+
// agent-writable like personas.json, and is safe there for a reason worth stating: a fenced conversation's own
|
|
79
|
+
// fence has to admit `.intentic` before its agent can reach this file, so no fence can widen itself.
|
|
80
|
+
{ path: ".intentic/config/areas.json", invalidates: ["areas", "manifests"], portability: "carry", versioned: true },
|
|
81
|
+
|
|
75
82
|
// Four files split by portability, not just prefix: `custom` is the owner-approved source and the only one that
|
|
76
83
|
// must travel; `approved` is composed from custom + capability fragments + the base image and is rebuilt on the
|
|
77
84
|
// target's first boot (carrying it would ship a `FROM` naming an image the target may not have); the proposal and
|
|
@@ -475,7 +482,7 @@ export const WORKSPACE_STATE_FILES: readonly WorkspaceStateFile[] = STATE_FILES;
|
|
|
475
482
|
// entry `versioned` is the only change needed.
|
|
476
483
|
export const VERSIONED_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter((file) => file.versioned).map((file) => file.path);
|
|
477
484
|
|
|
478
|
-
//
|
|
485
|
+
// What a workspace search may surface: `versioned` config plus `authored` content (approvals, staged docs,
|
|
479
486
|
// workspace extensions). Everything else under `.intentic` is machine state, denied by default. `auth/`, where
|
|
480
487
|
// vaulted credentials live, is always denied regardless.
|
|
481
488
|
export const SEARCHABLE_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter((file) => file.versioned || file.authored).map(
|
|
@@ -552,7 +559,7 @@ export const SHARED_STATE_PATHS: readonly string[] = STATE_GROUPS.flatMap((group
|
|
|
552
559
|
: [`${STATE_GROUP_DIR[group]}/`];
|
|
553
560
|
});
|
|
554
561
|
|
|
555
|
-
//
|
|
562
|
+
// What desktop-sync copies down: ordinary state and the records binding this sandbox to its owner, minus anything
|
|
556
563
|
// that opts out via `backup: false`. Deliberately not the same question as the export bundle: a bundle asks what
|
|
557
564
|
// may be reconstituted elsewhere, this asks what the owner may keep a copy of.
|
|
558
565
|
export const BACKED_UP_STATE_PATHS: readonly string[] = WORKSPACE_STATE_FILES.filter(
|
|
@@ -188,11 +188,9 @@ describe("foldTurn", () => {
|
|
|
188
188
|
outage: { retryAt: 1, attempt: 2, maxAttempts: 6 },
|
|
189
189
|
},
|
|
190
190
|
];
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
noticeAction: "outageOptOut",
|
|
195
|
-
});
|
|
191
|
+
// The row states what happened and nothing more: what happens next, and the one control that changes it, are
|
|
192
|
+
// the chat card's, asked once (ChatContinueStrip), not a second switch on a transcript line.
|
|
193
|
+
expect(foldOf("hi", outage).at(-1)).toEqual({ role: "notice", text: "Anthropic is down. Retrying by itself: attempt 2 of 6." });
|
|
196
194
|
const renewal: AgentEvent[] = [{ kind: "error", code: "claude-token-refused", message: "Token refused.", autoResume: "scheduled" }];
|
|
197
195
|
expect(foldOf("hi", renewal).at(-1)).toEqual({
|
|
198
196
|
role: "notice",
|
|
@@ -96,18 +96,14 @@ const errorRow = (event: Extract<AgentEvent, { kind: "error" }>): TranscriptRow
|
|
|
96
96
|
switch (code) {
|
|
97
97
|
case "provider-outage":
|
|
98
98
|
return event.outage === undefined
|
|
99
|
-
? { role: "notice", text: `${message} Nothing is retrying it, so the turn is waiting
|
|
100
|
-
: {
|
|
101
|
-
role: "notice",
|
|
102
|
-
text: `${message} Retrying by itself: attempt ${event.outage.attempt} of ${event.outage.maxAttempts}.`,
|
|
103
|
-
...(event.autoResume === "scheduled" ? { noticeAction: "outageOptOut" } : {}),
|
|
104
|
-
};
|
|
99
|
+
? { role: "notice", text: `${message} Nothing is retrying it, so the turn is waiting here.` }
|
|
100
|
+
: { role: "notice", text: `${message} Retrying by itself: attempt ${event.outage.attempt} of ${event.outage.maxAttempts}.` };
|
|
105
101
|
case "claude-token-refused":
|
|
106
102
|
return event.autoResume === "scheduled"
|
|
107
103
|
? { role: "notice", text: `${message} The credential is being renewed and this turn continues automatically.`, noticeWait: "credentialRenewal" }
|
|
108
104
|
: { role: "notice", text: `${message} Reconnect the account to pick this conversation back up.` };
|
|
109
105
|
case "rate_limit":
|
|
110
|
-
return { role: "notice", text:
|
|
106
|
+
return { role: "notice", text: message };
|
|
111
107
|
// Refused before the model saw it; the composer holds the message so the user can resend it.
|
|
112
108
|
case "claude-reauth":
|
|
113
109
|
case "unknown-command":
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { asCivilDay, asZone, civilDayIn, cronOptions, isZone, sameClock, utcDayOf, UTC, zoneLabel } from "./zone.js";
|
|
3
|
+
import type { Zone } from "./zone.js";
|
|
4
|
+
|
|
5
|
+
// The distinctions this module exists to keep, each of which was a live bug before it: a cron evaluated in the
|
|
6
|
+
// process's zone instead of the author's, a civil day rendered as if it were an instant, and a zone label shown to a
|
|
7
|
+
// reader who is already on that clock.
|
|
8
|
+
|
|
9
|
+
const WARSAW = "Europe/Warsaw" as Zone;
|
|
10
|
+
const NEW_YORK = "America/New_York" as Zone;
|
|
11
|
+
// 2026-09-20T20:43Z. Warsaw is +2 (summer time), New York -4.
|
|
12
|
+
const SEPTEMBER = Date.UTC(2026, 8, 20, 20, 43);
|
|
13
|
+
// 2026-12-20T20:43Z. Warsaw is +1, New York -5: the same two zones, different offsets.
|
|
14
|
+
const DECEMBER = Date.UTC(2026, 11, 20, 20, 43);
|
|
15
|
+
|
|
16
|
+
describe(`isZone`, () => {
|
|
17
|
+
test(`accepts what ICU knows and refuses what it does not`, () => {
|
|
18
|
+
expect(isZone(`Europe/Warsaw`)).toBe(true);
|
|
19
|
+
expect(isZone(`UTC`)).toBe(true);
|
|
20
|
+
expect(isZone(`Europe/Warszawa`)).toBe(false);
|
|
21
|
+
expect(isZone(``)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/* An offset is not a zone: it cannot say what happens in March, which is the whole reason schedules need a zone. */
|
|
25
|
+
test(`refuses a bare offset, which is the shape that looks like it would work`, () => {
|
|
26
|
+
expect(isZone(`+02:00`)).toBe(false);
|
|
27
|
+
expect(isZone(`UTC+2`)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test(`asZone passes real ids through and swallows the rest`, () => {
|
|
31
|
+
expect(asZone(`America/New_York`)).toBe(`America/New_York`);
|
|
32
|
+
expect(asZone(`Mars/Olympus`)).toBeUndefined();
|
|
33
|
+
expect(asZone(undefined)).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe(`civilDayIn`, () => {
|
|
38
|
+
/* THE ONE THAT MATTERS: one instant, three zones, three different calendar days. */
|
|
39
|
+
test(`an instant falls on different days depending on the zone asked`, () => {
|
|
40
|
+
// 2026-09-20T23:30Z: already the 21st in Warsaw, still the 20th in UTC and New York.
|
|
41
|
+
const lateEvening = Date.UTC(2026, 8, 20, 23, 30);
|
|
42
|
+
expect(civilDayIn(lateEvening, WARSAW)).toBe(`2026-09-21`);
|
|
43
|
+
expect(civilDayIn(lateEvening, UTC)).toBe(`2026-09-20`);
|
|
44
|
+
expect(civilDayIn(lateEvening, NEW_YORK)).toBe(`2026-09-20`);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test(`utcDayOf is civilDayIn UTC, spelled so every UTC bucket is greppable`, () => {
|
|
48
|
+
expect(utcDayOf(SEPTEMBER)).toBe(civilDayIn(SEPTEMBER, UTC));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test(`asCivilDay takes the shape and nothing else`, () => {
|
|
52
|
+
expect(asCivilDay(`2026-09-20`)).toBe(`2026-09-20`);
|
|
53
|
+
expect(asCivilDay(`2026-09-20T00:00:00Z`)).toBeUndefined();
|
|
54
|
+
expect(asCivilDay(`20/09/2026`)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe(`sameClock`, () => {
|
|
59
|
+
test(`two zones on the same offset right now read as one clock`, () => {
|
|
60
|
+
expect(sameClock(WARSAW, `Europe/Berlin` as Zone, SEPTEMBER)).toBe(true);
|
|
61
|
+
expect(sameClock(WARSAW, UTC, SEPTEMBER)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/* Asked at an instant, not in the abstract: London and Warsaw differ all year, London and UTC only in summer. */
|
|
65
|
+
test(`the answer is a property of the instant, not of the pair`, () => {
|
|
66
|
+
expect(sameClock(`Europe/London` as Zone, UTC, SEPTEMBER)).toBe(false);
|
|
67
|
+
expect(sameClock(`Europe/London` as Zone, UTC, DECEMBER)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe(`zoneLabel`, () => {
|
|
72
|
+
test(`names the zone for a reader on a different clock`, () => {
|
|
73
|
+
expect(zoneLabel(WARSAW, NEW_YORK, SEPTEMBER)).toBe(`Europe/Warsaw`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test(`stays quiet for a reader already on that clock, rather than adding width for nothing`, () => {
|
|
77
|
+
expect(zoneLabel(WARSAW, `Europe/Berlin` as Zone, SEPTEMBER)).toBeUndefined();
|
|
78
|
+
expect(zoneLabel(UTC, UTC, SEPTEMBER)).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test(`underscores become spaces, since the id is read as words here`, () => {
|
|
82
|
+
expect(zoneLabel(NEW_YORK, UTC, SEPTEMBER)).toBe(`America/New York`);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe(`cronOptions`, () => {
|
|
87
|
+
test(`hands croner the key it actually reads`, () => {
|
|
88
|
+
expect(cronOptions(WARSAW)).toEqual({ timezone: `Europe/Warsaw` });
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/time/zone.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The vocabulary for time values that cross a machine boundary, and the one rule behind all of it: an INSTANT means
|
|
2
|
+
// the same thing everywhere, a WALL RULE means nothing without a zone, and a CIVIL DAY is a zone's opinion wearing the
|
|
3
|
+
// costume of a fact.
|
|
4
|
+
//
|
|
5
|
+
// Intentic runs its daemon in a UTC container and its editor in whatever zone the reader's laptop is in. Anything that
|
|
6
|
+
// says "at 09:00" was authored on one of those machines and evaluated on the other, so the zone has to travel with it
|
|
7
|
+
// or the two disagree by the offset — silently, since both sides render a plausible number.
|
|
8
|
+
//
|
|
9
|
+
// Instants stay plain `number` (epoch ms) deliberately. That convention is already universal in this codebase and
|
|
10
|
+
// already safe; branding it would touch thousands of call sites to prevent a mistake nobody is making. The two types
|
|
11
|
+
// below are branded because they are the two that DO get confused for an instant, and each confusion is a shipped bug:
|
|
12
|
+
// a civil day rendered in the reader's zone slides a day westward, and a cron evaluated in the wrong zone fires at the
|
|
13
|
+
// wrong hour.
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A named IANA zone ("Europe/Warsaw"), never a fixed offset. An offset cannot express the summer-time rule that moves
|
|
18
|
+
* "09:00" twice a year, which is exactly what a recurring schedule has to survive.
|
|
19
|
+
*/
|
|
20
|
+
export type Zone = string & { readonly __brand: "Zone" };
|
|
21
|
+
|
|
22
|
+
/** The daemon's own zone, and the answer for a sandbox whose owner has never said where they are. */
|
|
23
|
+
export const UTC = "UTC" as Zone;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether ICU knows this id AND it names a place rather than an offset. The platform's own tables are the only honest
|
|
27
|
+
* source for the first half; the second half has to be enforced here, because ICU accepts `+02:00` as a `timeZone`
|
|
28
|
+
* since ES2024 and that is exactly the shape a naive `getTimezoneOffset()` would produce. A fixed offset is frozen at
|
|
29
|
+
* the moment it was read: a schedule stored as `+02:00` in September fires an hour off for the whole of the winter.
|
|
30
|
+
* `Etc/GMT+5` is left alone — it is a real id somebody may genuinely be on, not an accident of arithmetic.
|
|
31
|
+
*/
|
|
32
|
+
export const isZone = (value: string): value is Zone => {
|
|
33
|
+
if (value.startsWith("+") || value.startsWith("-")) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return Intl.DateTimeFormat("en", { timeZone: value }).resolvedOptions().timeZone !== "";
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** An id from outside (a manifest, a migrated config, a browser) as a `Zone`, or undefined if ICU does not know it. */
|
|
44
|
+
export const asZone = (value: string | undefined): Zone | undefined => (value !== undefined && isZone(value) ? value : undefined);
|
|
45
|
+
|
|
46
|
+
// Rejects at the edge rather than at the croner call: an unknown zone in a manifest should fail the save that
|
|
47
|
+
// introduced it, where somebody is still watching, not the tick three hours later.
|
|
48
|
+
export const ZoneSchema = z.string().refine(isZone, { message: "Not a zone name ICU knows, e.g. Europe/Warsaw or UTC." });
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The zone the reader's own machine is in. Browser-side only by nature — in the daemon this answers UTC, which is true
|
|
52
|
+
* but useless, and is why the sandbox keeps a zone setting instead of asking its own clock.
|
|
53
|
+
*/
|
|
54
|
+
export const localZone = (): Zone => Intl.DateTimeFormat().resolvedOptions().timeZone as Zone;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A calendar day as `YYYY-MM-DD`, carrying no time and no zone of its own. Branded so it cannot be handed to a
|
|
58
|
+
* formatter expecting an instant: `new Date("2026-09-20")` parses as UTC midnight, and rendering that in a zone behind
|
|
59
|
+
* UTC prints the 19th. That bug is invisible to whoever writes it, because it is correct in their own zone.
|
|
60
|
+
*/
|
|
61
|
+
export type CivilDay = string & { readonly __brand: "CivilDay" };
|
|
62
|
+
|
|
63
|
+
export const CivilDaySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "A calendar day as YYYY-MM-DD.");
|
|
64
|
+
|
|
65
|
+
/** The calendar day an instant falls on, in a named zone. `en-CA` because its short date format IS ISO order. */
|
|
66
|
+
export const civilDayIn = (at: number, zone: Zone): CivilDay =>
|
|
67
|
+
new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(at) as CivilDay;
|
|
68
|
+
|
|
69
|
+
/** The UTC calendar day of an instant. Named rather than spelled inline, so every UTC bucket in the tree is greppable. */
|
|
70
|
+
export const utcDayOf = (at: number): CivilDay => new Date(at).toISOString().slice(0, 10) as CivilDay;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An instant as a person on a named clock would read it: "2026-09-20 20:43". For text the daemon writes — a log line,
|
|
74
|
+
* a note handed to an agent — where an ISO stamp is unambiguous but unreadable, and where the daemon cannot use the
|
|
75
|
+
* editor's formatters because there is no reader in the room. Never for the interface: on screen an instant is drawn
|
|
76
|
+
* in the reader's own clock, by `_editor/ui/src/lib/format.ts`.
|
|
77
|
+
* `sv-SE` because its short format is ISO-shaped, which is the point: no locale ambiguity in a machine-written line.
|
|
78
|
+
*/
|
|
79
|
+
export const wallClockIn = (at: number, zone: Zone): string =>
|
|
80
|
+
new Intl.DateTimeFormat("sv-SE", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })
|
|
81
|
+
.format(at)
|
|
82
|
+
.replace(",", "");
|
|
83
|
+
|
|
84
|
+
/** A `YYYY-MM-DD` from a store, a query string or a fixture. Undefined for anything that is not one. */
|
|
85
|
+
export const asCivilDay = (value: string | undefined): CivilDay | undefined => (value !== undefined && /^\d{4}-\d{2}-\d{2}$/.test(value) ? (value as CivilDay) : undefined);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A recurring wall-clock rule: the cron AND the zone it is meant in, which is the only form in which it means anything.
|
|
89
|
+
* Kept as one type so the two cannot be passed around separately and drift apart — the original bug was a `cron:
|
|
90
|
+
* string` travelling alone from a browser in Europe/Warsaw to a daemon in UTC.
|
|
91
|
+
*/
|
|
92
|
+
export interface WallRule {
|
|
93
|
+
readonly cron: string;
|
|
94
|
+
readonly tz: Zone;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What croner has to be given. Never call `new Cron(expr)` bare: that silently means "in whatever zone this process is". */
|
|
98
|
+
export const cronOptions = (tz: Zone): { timezone: string } => ({ timezone: tz });
|
|
99
|
+
|
|
100
|
+
// The offset of a zone at an instant, in minutes east of UTC. Via the formatter's own `longOffset` part, since the
|
|
101
|
+
// arithmetic alternative (formatting twice and subtracting) gets the summer-time changeover hour wrong.
|
|
102
|
+
const offsetMinutes = (at: number, zone: Zone): number => {
|
|
103
|
+
const part = new Intl.DateTimeFormat("en", { timeZone: zone, timeZoneName: "longOffset" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
|
|
104
|
+
const match = /GMT([+-])(\d{2}):(\d{2})/.exec(part ?? "");
|
|
105
|
+
if (match === null) {
|
|
106
|
+
return 0; // "GMT" with no offset is UTC itself.
|
|
107
|
+
}
|
|
108
|
+
return (match[1] === "-" ? -1 : 1) * (Number(match[2]) * 60 + Number(match[3]));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Whether two zones are showing the same wall clock right now — which is when naming one on screen would be noise. */
|
|
112
|
+
export const sameClock = (a: Zone, b: Zone, at: number = Date.now()): boolean => a === b || offsetMinutes(at, a) === offsetMinutes(at, b);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How to name a zone beside a wall-clock time, or undefined when the reader is already on that clock and the label
|
|
116
|
+
* would only add width. The city, not the offset: "Europe/Warsaw" survives the March changeover, "UTC+2" does not.
|
|
117
|
+
*/
|
|
118
|
+
export const zoneLabel = (rule: Zone, reader: Zone, at: number = Date.now()): string | undefined => (sameClock(rule, reader, at) ? undefined : rule.replace(/_/g, " "));
|