@intentic/sandbox-contract 1.310.0 → 1.312.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/README.md +43 -159
- package/dist/chores/chores.js +6 -6
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/fix-deps.d.ts.map +1 -1
- package/dist/chores/fix-deps.js +3 -2
- package/dist/chores/fix-deps.js.map +1 -1
- package/dist/chores/prompt.d.ts.map +1 -1
- package/dist/chores/prompt.js +3 -2
- package/dist/chores/prompt.js.map +1 -1
- package/dist/contracts/accounts.contract.d.ts +15 -0
- package/dist/contracts/accounts.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +15 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +1228 -32
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +42 -3
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +2 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/device.contract.d.ts +2 -0
- package/dist/contracts/device.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +2 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/offload.contract.d.ts +107 -0
- package/dist/contracts/offload.contract.d.ts.map +1 -0
- package/dist/contracts/offload.contract.js +53 -0
- package/dist/contracts/offload.contract.js.map +1 -0
- package/dist/contracts/ports.contract.d.ts +5 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +111 -0
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.js +3 -1
- package/dist/contracts/runner.contract.js.map +1 -1
- package/dist/contracts/settings.contract.d.ts +16 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +154 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.d.ts +16 -0
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +146 -0
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +40 -3
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/documents/conversions.d.ts +81 -0
- package/dist/documents/conversions.d.ts.map +1 -0
- package/dist/documents/conversions.js +213 -0
- package/dist/documents/conversions.js.map +1 -0
- package/dist/documents/index.d.ts +3 -0
- package/dist/documents/index.d.ts.map +1 -0
- package/dist/documents/index.js +3 -0
- package/dist/documents/index.js.map +1 -0
- package/dist/documents/passthrough.d.ts +13 -0
- package/dist/documents/passthrough.d.ts.map +1 -0
- package/dist/documents/passthrough.js +101 -0
- package/dist/documents/passthrough.js.map +1 -0
- package/dist/events/agent-events.d.ts +20 -0
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/agent-events.js +6 -1
- package/dist/events/agent-events.js.map +1 -1
- package/dist/events/land-breakage.d.ts +3 -0
- package/dist/events/land-breakage.d.ts.map +1 -1
- package/dist/events/land-breakage.js +3 -0
- package/dist/events/land-breakage.js.map +1 -1
- package/dist/events/land-conflict.d.ts +3 -0
- package/dist/events/land-conflict.d.ts.map +1 -0
- package/dist/events/land-conflict.js +4 -0
- package/dist/events/land-conflict.js.map +1 -0
- package/dist/events/system-events.d.ts +114 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/system-events.js +3 -1
- package/dist/events/system-events.js.map +1 -1
- package/dist/events/transcript.d.ts +5 -0
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/events/transcript.js +9 -1
- package/dist/events/transcript.js.map +1 -1
- package/dist/events/verify-nudge.d.ts.map +1 -1
- package/dist/events/verify-nudge.js.map +1 -1
- package/dist/front/generated/wire.d.ts +137 -0
- package/dist/front/generated/wire.d.ts.map +1 -0
- package/dist/front/generated/wire.js +2 -0
- package/dist/front/generated/wire.js.map +1 -0
- package/dist/front/terminal-frames.d.ts +17 -0
- package/dist/front/terminal-frames.d.ts.map +1 -0
- package/dist/front/terminal-frames.js +79 -0
- package/dist/front/terminal-frames.js.map +1 -0
- package/dist/ids/conversation-ids.d.ts +2 -0
- package/dist/ids/conversation-ids.d.ts.map +1 -1
- package/dist/ids/conversation-ids.js +2 -0
- package/dist/ids/conversation-ids.js.map +1 -1
- package/dist/index.d.ts +1773 -96
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/policy/command-run.d.ts +1 -1
- package/dist/policy/command-run.d.ts.map +1 -1
- package/dist/policy/command-run.js +7 -4
- package/dist/policy/command-run.js.map +1 -1
- package/dist/protocol/container-requirements.d.ts +0 -1
- package/dist/protocol/container-requirements.d.ts.map +1 -1
- package/dist/protocol/container-requirements.js +1 -2
- package/dist/protocol/container-requirements.js.map +1 -1
- package/dist/protocol/ingress-contract.d.ts +0 -11
- package/dist/protocol/ingress-contract.d.ts.map +1 -1
- package/dist/protocol/raw-routes.d.ts +36 -4
- package/dist/protocol/raw-routes.d.ts.map +1 -1
- package/dist/protocol/raw-routes.js +18 -13
- package/dist/protocol/raw-routes.js.map +1 -1
- package/dist/protocol/route-meta.d.ts +2 -0
- package/dist/protocol/route-meta.d.ts.map +1 -1
- package/dist/protocol/route-meta.js.map +1 -1
- package/dist/protocol/runner-protocol.d.ts +34 -0
- package/dist/protocol/runner-protocol.d.ts.map +1 -1
- package/dist/protocol/runner-protocol.js +33 -0
- package/dist/protocol/runner-protocol.js.map +1 -1
- package/dist/protocol/tunnel-lanes.d.ts +10 -0
- package/dist/protocol/tunnel-lanes.d.ts.map +1 -0
- package/dist/protocol/tunnel-lanes.js +16 -0
- package/dist/protocol/tunnel-lanes.js.map +1 -0
- package/dist/schemas/activity.d.ts.map +1 -1
- package/dist/schemas/activity.js.map +1 -1
- package/dist/schemas/agents.d.ts +145 -3
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +43 -9
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +43 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/capabilities.d.ts +3 -0
- package/dist/schemas/capabilities.d.ts.map +1 -1
- package/dist/schemas/capabilities.js +4 -0
- package/dist/schemas/capabilities.js.map +1 -1
- package/dist/schemas/devices.d.ts +34 -6
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +8 -6
- package/dist/schemas/devices.js.map +1 -1
- package/dist/schemas/extension-updates.d.ts +4 -0
- package/dist/schemas/extension-updates.d.ts.map +1 -1
- package/dist/schemas/git/git.d.ts +16 -0
- package/dist/schemas/git/git.d.ts.map +1 -1
- package/dist/schemas/git/git.js +1 -0
- package/dist/schemas/git/git.js.map +1 -1
- package/dist/schemas/hosts.d.ts +8 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +3 -0
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/keep-warm.d.ts +68 -0
- package/dist/schemas/keep-warm.d.ts.map +1 -0
- package/dist/schemas/keep-warm.js +74 -0
- package/dist/schemas/keep-warm.js.map +1 -0
- package/dist/schemas/metrics.d.ts +7 -0
- package/dist/schemas/metrics.d.ts.map +1 -1
- package/dist/schemas/metrics.js +1 -0
- package/dist/schemas/metrics.js.map +1 -1
- package/dist/schemas/offload.d.ts +62 -0
- package/dist/schemas/offload.d.ts.map +1 -0
- package/dist/schemas/offload.js +23 -0
- package/dist/schemas/offload.js.map +1 -0
- package/dist/schemas/personas.js +3 -3
- package/dist/schemas/personas.js.map +1 -1
- package/dist/schemas/ports.d.ts +10 -0
- package/dist/schemas/ports.d.ts.map +1 -1
- package/dist/schemas/ports.js +8 -0
- package/dist/schemas/ports.js.map +1 -1
- package/dist/schemas/providers/plan-limits.d.ts +29 -0
- package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
- package/dist/schemas/providers/plan-limits.js +7 -0
- package/dist/schemas/providers/plan-limits.js.map +1 -1
- package/dist/schemas/providers/provider-oauth.d.ts +15 -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 +6 -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-history.d.ts +28 -0
- package/dist/schemas/settings-history.d.ts.map +1 -0
- package/dist/schemas/settings-history.js +39 -0
- package/dist/schemas/settings-history.js.map +1 -0
- package/dist/schemas/settings.d.ts +8 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +33 -3
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/system.d.ts +24 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +15 -0
- package/dist/schemas/system.js.map +1 -1
- package/dist/schemas/workspace/mainline.d.ts +371 -0
- package/dist/schemas/workspace/mainline.d.ts.map +1 -0
- package/dist/schemas/workspace/mainline.js +134 -0
- package/dist/schemas/workspace/mainline.js.map +1 -0
- package/dist/schemas/workspace/workspace-tree.d.ts +23 -0
- package/dist/schemas/workspace/workspace-tree.d.ts.map +1 -1
- package/dist/schemas/workspace/workspace-tree.js +34 -0
- package/dist/schemas/workspace/workspace-tree.js.map +1 -1
- package/dist/state/definition.d.ts +36 -4
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/definition.js.map +1 -1
- package/dist/state/fix-stance.d.ts.map +1 -1
- package/dist/state/fix-stance.js +1 -0
- package/dist/state/fix-stance.js.map +1 -1
- package/dist/state/history-state.d.ts.map +1 -1
- package/dist/state/history-state.js +5 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/state/runtime-state.d.ts +6 -0
- package/dist/state/runtime-state.d.ts.map +1 -1
- package/dist/state/runtime-state.js +2 -0
- package/dist/state/runtime-state.js.map +1 -1
- package/dist/state/workspace-state.d.ts +22 -1
- package/dist/state/workspace-state.d.ts.map +1 -1
- package/dist/state/workspace-state.js +27 -1
- package/dist/state/workspace-state.js.map +1 -1
- package/dist/text/transcript-fold.d.ts +3 -0
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +30 -8
- package/dist/text/transcript-fold.js.map +1 -1
- package/dist/time/zone.d.ts +1 -0
- package/dist/time/zone.d.ts.map +1 -1
- package/dist/time/zone.js +5 -0
- package/dist/time/zone.js.map +1 -1
- package/package.json +34 -22
- package/src/chores/chores.ts +6 -6
- package/src/chores/fix-deps.ts +3 -2
- package/src/chores/prompt.ts +3 -2
- package/src/contracts/agents.contract.ts +50 -3
- package/src/contracts/offload.contract.ts +60 -0
- package/src/contracts/runner.contract.ts +7 -1
- package/src/contracts/workspace.contract.ts +54 -2
- package/src/documents/conversions.ts +402 -0
- package/src/documents/index.ts +5 -0
- package/src/documents/passthrough.ts +139 -0
- package/src/events/agent-events.ts +9 -1
- package/src/events/land-breakage.ts +12 -0
- package/src/events/land-conflict.ts +12 -0
- package/src/events/system-events.ts +9 -3
- package/src/events/transcript.ts +14 -3
- package/src/events/verify-nudge.ts +4 -3
- package/src/front/generated/wire.ts +104 -0
- package/src/front/terminal-frames.fixture.json +55 -0
- package/src/front/terminal-frames.test.ts +63 -0
- package/src/front/terminal-frames.ts +102 -0
- package/src/ids/conversation-ids.ts +7 -0
- package/src/ids/hostnames.fixture.json +20 -0
- package/src/ids/hostnames.test.ts +20 -0
- package/src/index.ts +8 -1
- package/src/policy/command-run.test.ts +35 -0
- package/src/policy/command-run.ts +10 -5
- package/src/protocol/container-requirements.test.ts +2 -3
- package/src/protocol/container-requirements.ts +1 -7
- package/src/protocol/ingress-contract.fixture.json +77 -0
- package/src/protocol/ingress-contract.test.ts +18 -26
- package/src/protocol/ingress-contract.ts +3 -22
- package/src/protocol/raw-routes.ts +21 -14
- package/src/protocol/route-meta.ts +4 -0
- package/src/protocol/runner-protocol.ts +53 -0
- package/src/protocol/tunnel-lanes.json +136 -0
- package/src/protocol/tunnel-lanes.test.ts +30 -0
- package/src/protocol/tunnel-lanes.ts +32 -0
- package/src/schemas/activity.ts +3 -1
- package/src/schemas/agents.ts +66 -10
- package/src/schemas/capabilities.ts +6 -0
- package/src/schemas/devices.ts +16 -9
- package/src/schemas/git/git.ts +5 -0
- package/src/schemas/hosts.ts +8 -0
- package/src/schemas/keep-warm.test.ts +68 -0
- package/src/schemas/keep-warm.ts +124 -0
- package/src/schemas/metrics.ts +2 -0
- package/src/schemas/offload.ts +44 -0
- package/src/schemas/personas.ts +3 -3
- package/src/schemas/ports.ts +10 -0
- package/src/schemas/providers/plan-limits.ts +13 -0
- package/src/schemas/providers/provider-oauth.ts +6 -0
- package/src/schemas/providers/usage.ts +7 -0
- package/src/schemas/settings-history.ts +61 -0
- package/src/schemas/settings.ts +70 -19
- package/src/schemas/system.ts +17 -0
- package/src/schemas/workspace/mainline.ts +201 -0
- package/src/schemas/workspace/workspace-tree.ts +47 -0
- package/src/state/definition.ts +3 -2
- package/src/state/fix-stance.test.ts +14 -0
- package/src/state/fix-stance.ts +2 -0
- package/src/state/history-state.ts +15 -0
- package/src/state/runtime-state.ts +8 -0
- package/src/state/workspace-state.ts +35 -1
- package/src/text/transcript-fold.test.ts +53 -1
- package/src/text/transcript-fold.ts +53 -10
- package/src/time/zone.ts +7 -0
- package/src/webext/index.ts +1 -1
- package/dist/protocol/ingress-protocol.d.ts +0 -30
- package/dist/protocol/ingress-protocol.d.ts.map +0 -1
- package/dist/protocol/ingress-protocol.js +0 -285
- package/dist/protocol/ingress-protocol.js.map +0 -1
- package/dist/protocol/terminal-protocol.d.ts +0 -18
- package/dist/protocol/terminal-protocol.d.ts.map +0 -1
- package/dist/protocol/terminal-protocol.js +0 -2
- package/dist/protocol/terminal-protocol.js.map +0 -1
- package/dist/protocol/tunnel-heartbeat.d.ts +0 -19
- package/dist/protocol/tunnel-heartbeat.d.ts.map +0 -1
- package/dist/protocol/tunnel-heartbeat.js +0 -41
- package/dist/protocol/tunnel-heartbeat.js.map +0 -1
- package/src/protocol/ingress-protocol.test.ts +0 -535
- package/src/protocol/ingress-protocol.ts +0 -448
- package/src/protocol/terminal-protocol.ts +0 -13
- package/src/protocol/tunnel-heartbeat.test.ts +0 -75
- package/src/protocol/tunnel-heartbeat.ts +0 -67
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// A stored document's history as data: each conversion is a guarded, pure rewrite of the raw JSON a store reads, run
|
|
2
|
+
// before the schema on every read. The guard makes a conversion a no-op on a document already past it, so running the
|
|
3
|
+
// whole list over a document from any era converges on today's shape without the document carrying a version, and
|
|
4
|
+
// every door old bytes arrive through (an update, a bundle, a git revert, a history restore) is covered by the read.
|
|
5
|
+
// Two rules keep that sound, and the vocabulary below only offers moves that obey them: a change of meaning ships as a
|
|
6
|
+
// rename, never as a new reading of the same key; and a retired key is never reused.
|
|
7
|
+
|
|
8
|
+
// The only thing a conversion rewrites: one JSON object, never mutated, replaced along the branch that changed.
|
|
9
|
+
export type JsonObject = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export const isJsonObject = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
|
|
13
|
+
// What one conversion did at one place in a document, for the ledger and the pre-flight plan. `at` is the path inside
|
|
14
|
+
// the document ("" is the document itself, "[3]" its fourth entry, "trigger" a nested object).
|
|
15
|
+
export interface ConversionChange {
|
|
16
|
+
readonly conversion: string;
|
|
17
|
+
readonly at: string;
|
|
18
|
+
readonly detail?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ConversionBase<Kind extends string> {
|
|
22
|
+
readonly kind: Kind;
|
|
23
|
+
// One line a person reads in a plan or the ledger: "renames agentRunModel to agentRunModels".
|
|
24
|
+
readonly describe: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RenameConversion<From extends string = string, To extends string = string> extends ConversionBase<"rename"> {
|
|
28
|
+
readonly from: From;
|
|
29
|
+
readonly to: To;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DropConversion<Key extends string = string> extends ConversionBase<"drop"> {
|
|
33
|
+
readonly key: Key;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RetypeConversion<Key extends string = string, In = unknown, Out = unknown> extends ConversionBase<"retype"> {
|
|
37
|
+
readonly key: Key;
|
|
38
|
+
// True only for the OLD representation; a converted value must fail it, or the conversion would run twice.
|
|
39
|
+
readonly guard: (value: unknown) => value is In;
|
|
40
|
+
// Method syntax on purpose: the guard proves `In` at runtime, so a union of conversions may hold any input type.
|
|
41
|
+
convert(value: In): Out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MapValueConversion<Key extends string = string, Mapping extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>>
|
|
45
|
+
extends ConversionBase<"mapValue"> {
|
|
46
|
+
readonly key: Key;
|
|
47
|
+
readonly mapping: Mapping;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PinDefaultConversion<Key extends string = string, Value = unknown> extends ConversionBase<"pinDefault"> {
|
|
51
|
+
readonly key: Key;
|
|
52
|
+
readonly value: Value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TransformConversion<In extends JsonObject = JsonObject, Out extends JsonObject = JsonObject> extends ConversionBase<"transform"> {
|
|
56
|
+
// True only for the old shape; the output must fail it.
|
|
57
|
+
readonly applies: (value: JsonObject) => value is In;
|
|
58
|
+
// Method syntax on purpose, as for retype.
|
|
59
|
+
convert(value: In): Out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Several keys become one (three optional fields folded into a ladder): the old keys go, the new one takes the place of
|
|
63
|
+
// the first of them.
|
|
64
|
+
export interface FoldConversion<From extends string = string, Into extends string = string, Out = unknown> extends ConversionBase<"fold"> {
|
|
65
|
+
readonly from: readonly From[];
|
|
66
|
+
readonly into: Into;
|
|
67
|
+
// True only for the old shape; the folded output must fail it.
|
|
68
|
+
readonly applies: (value: JsonObject) => boolean;
|
|
69
|
+
// Method syntax on purpose, as for retype. `whole` is the object before folding, for a value derived from elsewhere.
|
|
70
|
+
convert(fields: Readonly<Partial<Record<From, unknown>>>, whole: JsonObject): Out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Entries of a list this version withdrew (a capability kind that no longer exists): removed, each one recorded. Only
|
|
74
|
+
// for entries nothing can convert; one with a successor is converted instead (mapValue, fold).
|
|
75
|
+
export interface RetireEntriesConversion<Retired = unknown> extends ConversionBase<"retireEntries"> {
|
|
76
|
+
readonly isRetired: (entry: unknown) => entry is Retired;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AtConversion<Path extends string = string, Inner = unknown> extends ConversionBase<"at"> {
|
|
80
|
+
// Dotted keys; `*` is every element of an array or every value of an object.
|
|
81
|
+
readonly path: Path;
|
|
82
|
+
readonly inner: Inner;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type Conversion =
|
|
86
|
+
| RenameConversion
|
|
87
|
+
| DropConversion
|
|
88
|
+
| RetypeConversion
|
|
89
|
+
| MapValueConversion
|
|
90
|
+
| PinDefaultConversion
|
|
91
|
+
| TransformConversion
|
|
92
|
+
| FoldConversion
|
|
93
|
+
| RetireEntriesConversion
|
|
94
|
+
| AtConversion<string, Conversion>;
|
|
95
|
+
|
|
96
|
+
// Where a document's conversions apply: the document itself, each entry of a top-level array, or each value of a
|
|
97
|
+
// top-level object keyed by id.
|
|
98
|
+
export type Granularity = "object" | "entries" | "record";
|
|
99
|
+
|
|
100
|
+
// The value a conversion leaves in a change record, short enough for a log line.
|
|
101
|
+
const glimpse = (value: unknown): string => {
|
|
102
|
+
// String() over the result, since JSON.stringify answers undefined for a value JSON cannot spell.
|
|
103
|
+
const spelled = String(JSON.stringify(value));
|
|
104
|
+
return spelled.length > 80 ? `${spelled.slice(0, 77)}…` : spelled;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Keeps key order: a renamed key takes its predecessor's place, so a tracked file's diff shows one line changing.
|
|
108
|
+
const renamedKey = (target: JsonObject, from: string, to: string): JsonObject =>
|
|
109
|
+
Object.fromEntries(Object.entries(target).map(([key, value]) => [key === from ? to : key, value]));
|
|
110
|
+
|
|
111
|
+
const withoutKey = (target: JsonObject, key: string): JsonObject => Object.fromEntries(Object.entries(target).filter(([name]) => name !== key));
|
|
112
|
+
|
|
113
|
+
export const rename = <const From extends string, const To extends string>(from: From, to: To): RenameConversion<From, To> => ({
|
|
114
|
+
kind: "rename",
|
|
115
|
+
describe: `renames ${from} to ${to}`,
|
|
116
|
+
from,
|
|
117
|
+
to,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const drop = <const Key extends string>(key: Key): DropConversion<Key> => ({ kind: "drop", describe: `drops ${key}`, key });
|
|
121
|
+
|
|
122
|
+
export const retype = <const Key extends string, In, Out>(
|
|
123
|
+
key: Key,
|
|
124
|
+
guard: (value: unknown) => value is In,
|
|
125
|
+
convert: (value: In) => Out,
|
|
126
|
+
describe = `converts ${key} to its new form`,
|
|
127
|
+
): RetypeConversion<Key, In, Out> => ({ kind: "retype", describe, key, guard, convert });
|
|
128
|
+
|
|
129
|
+
// A mapped value that is itself a key of the mapping would be mapped again on the next read.
|
|
130
|
+
export const mapValue = <const Key extends string, const Mapping extends Readonly<Record<string, unknown>>>(
|
|
131
|
+
key: Key,
|
|
132
|
+
mapping: Mapping,
|
|
133
|
+
): MapValueConversion<Key, Mapping> => {
|
|
134
|
+
const cyclic = Object.values(mapping).filter((mapped): mapped is string => typeof mapped === "string" && Object.hasOwn(mapping, mapped));
|
|
135
|
+
if (cyclic.length > 0) {
|
|
136
|
+
throw new Error(`mapValue(${key}) maps onto its own keys (${cyclic.join(", ")}), so it would not settle`);
|
|
137
|
+
}
|
|
138
|
+
return { kind: "mapValue", describe: `converts ${key} from its old values`, key, mapping };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// For a changed schema default: an existing file that never set the key keeps the old behavior, a new one gets the new.
|
|
142
|
+
export const pinDefault = <const Key extends string, const Value>(key: Key, value: Value): PinDefaultConversion<Key, Value> => ({
|
|
143
|
+
kind: "pinDefault",
|
|
144
|
+
describe: `keeps the earlier default for ${key}`,
|
|
145
|
+
key,
|
|
146
|
+
value,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export const transform = <In extends JsonObject, Out extends JsonObject>(
|
|
150
|
+
describe: string,
|
|
151
|
+
applies: (value: JsonObject) => value is In,
|
|
152
|
+
convert: (value: In) => Out,
|
|
153
|
+
): TransformConversion<In, Out> => ({ kind: "transform", describe, applies, convert });
|
|
154
|
+
|
|
155
|
+
export const fold = <const From extends string, const Into extends string, Out>(
|
|
156
|
+
describe: string,
|
|
157
|
+
shape: {
|
|
158
|
+
readonly from: readonly From[];
|
|
159
|
+
readonly into: Into;
|
|
160
|
+
readonly applies: (value: JsonObject) => boolean;
|
|
161
|
+
readonly convert: (fields: Readonly<Partial<Record<From, unknown>>>, whole: JsonObject) => Out;
|
|
162
|
+
},
|
|
163
|
+
): FoldConversion<From, Into, Out> => ({ kind: "fold", describe, from: shape.from, into: shape.into, applies: shape.applies, convert: shape.convert });
|
|
164
|
+
|
|
165
|
+
// Every key a schema once had and no longer has, dropped: retired, not from the future, so passthrough stops carrying it
|
|
166
|
+
// and no later schema may reuse the name. A tuple, so the type-level replay sees each key.
|
|
167
|
+
export const dropAll = <const Keys extends readonly string[]>(keys: Keys): { readonly [I in keyof Keys]: DropConversion<Keys[I] & string> } =>
|
|
168
|
+
keys.map((key) => drop(key)) as unknown as { readonly [I in keyof Keys]: DropConversion<Keys[I] & string> };
|
|
169
|
+
|
|
170
|
+
type Nested<Path extends string, History extends readonly Conversion[], Entries extends boolean> = {
|
|
171
|
+
readonly [I in keyof History]: History[I] extends RetireEntriesConversion
|
|
172
|
+
? AtConversion<Path, History[I]>
|
|
173
|
+
: AtConversion<Entries extends true ? `${Path}.*` : Path, History[I]>;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// One document's history applied where another document carries it (a definition's `settings` table, a bundle's
|
|
177
|
+
// embedded definition): one declaration, every carrier. `entries` for a carried list, whose entries each convert and
|
|
178
|
+
// whose retired entries leave the list.
|
|
179
|
+
export const nested = <const Path extends string, const History extends readonly Conversion[], const Entries extends boolean = false>(
|
|
180
|
+
path: Path,
|
|
181
|
+
history: History,
|
|
182
|
+
entries?: Entries,
|
|
183
|
+
): Nested<Path, History, Entries> =>
|
|
184
|
+
history.map((conversion) => at(conversion.kind === "retireEntries" || entries !== true ? path : `${path}.*`, conversion)) as unknown as Nested<
|
|
185
|
+
Path,
|
|
186
|
+
History,
|
|
187
|
+
Entries
|
|
188
|
+
>;
|
|
189
|
+
|
|
190
|
+
export const retireEntries = <Retired>(describe: string, isRetired: (entry: unknown) => entry is Retired): RetireEntriesConversion<Retired> => ({
|
|
191
|
+
kind: "retireEntries",
|
|
192
|
+
describe,
|
|
193
|
+
isRetired,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
export const at = <const Path extends string, const Inner extends Conversion>(path: Path, inner: Inner): AtConversion<Path, Inner> => ({
|
|
197
|
+
kind: "at",
|
|
198
|
+
describe: `${inner.describe} under ${path}`,
|
|
199
|
+
path,
|
|
200
|
+
inner,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
type Report = (change: ConversionChange) => void;
|
|
204
|
+
|
|
205
|
+
// One conversion over one object: the same object back, by reference, when its guard says it does not apply.
|
|
206
|
+
const applyToObject = (conversion: Conversion, target: JsonObject, where: string, report: Report): JsonObject => {
|
|
207
|
+
switch (conversion.kind) {
|
|
208
|
+
case "rename": {
|
|
209
|
+
if (!Object.hasOwn(target, conversion.from)) {
|
|
210
|
+
return target;
|
|
211
|
+
}
|
|
212
|
+
if (!Object.hasOwn(target, conversion.to)) {
|
|
213
|
+
report({ conversion: conversion.describe, at: where });
|
|
214
|
+
return renamedKey(target, conversion.from, conversion.to);
|
|
215
|
+
}
|
|
216
|
+
// Both names holding the same value is a file written under both on purpose (a rename's grace window keeps
|
|
217
|
+
// the old name for a build that only knows it): nothing to report, nothing for the file to lose.
|
|
218
|
+
if (JSON.stringify(target[conversion.from]) === JSON.stringify(target[conversion.to])) {
|
|
219
|
+
return withoutKey(target, conversion.from);
|
|
220
|
+
}
|
|
221
|
+
// Both names present and different (an old key back through git, or an older build's edit after the window):
|
|
222
|
+
// the current name wins, and what the older one held is recorded rather than lost without a word.
|
|
223
|
+
report({ conversion: conversion.describe, at: where, detail: `kept ${conversion.to}; ${conversion.from} held ${glimpse(target[conversion.from])}` });
|
|
224
|
+
return withoutKey(target, conversion.from);
|
|
225
|
+
}
|
|
226
|
+
case "drop": {
|
|
227
|
+
if (!Object.hasOwn(target, conversion.key)) {
|
|
228
|
+
return target;
|
|
229
|
+
}
|
|
230
|
+
report({ conversion: conversion.describe, at: where, detail: `it held ${glimpse(target[conversion.key])}` });
|
|
231
|
+
return withoutKey(target, conversion.key);
|
|
232
|
+
}
|
|
233
|
+
case "retype": {
|
|
234
|
+
const value = target[conversion.key];
|
|
235
|
+
if (!Object.hasOwn(target, conversion.key) || !conversion.guard(value)) {
|
|
236
|
+
return target;
|
|
237
|
+
}
|
|
238
|
+
report({ conversion: conversion.describe, at: where });
|
|
239
|
+
return { ...target, [conversion.key]: conversion.convert(value) };
|
|
240
|
+
}
|
|
241
|
+
case "mapValue": {
|
|
242
|
+
const value = target[conversion.key];
|
|
243
|
+
if (typeof value !== "string" || !Object.hasOwn(conversion.mapping, value)) {
|
|
244
|
+
return target;
|
|
245
|
+
}
|
|
246
|
+
report({ conversion: conversion.describe, at: where, detail: `${glimpse(value)} became ${glimpse(conversion.mapping[value])}` });
|
|
247
|
+
return { ...target, [conversion.key]: conversion.mapping[value] };
|
|
248
|
+
}
|
|
249
|
+
case "pinDefault": {
|
|
250
|
+
if (Object.hasOwn(target, conversion.key)) {
|
|
251
|
+
return target;
|
|
252
|
+
}
|
|
253
|
+
report({ conversion: conversion.describe, at: where });
|
|
254
|
+
return { ...target, [conversion.key]: conversion.value };
|
|
255
|
+
}
|
|
256
|
+
case "transform": {
|
|
257
|
+
if (!conversion.applies(target)) {
|
|
258
|
+
return target;
|
|
259
|
+
}
|
|
260
|
+
report({ conversion: conversion.describe, at: where });
|
|
261
|
+
return conversion.convert(target);
|
|
262
|
+
}
|
|
263
|
+
case "fold":
|
|
264
|
+
return applyFold(conversion, target, where, report);
|
|
265
|
+
case "retireEntries":
|
|
266
|
+
// An object is not a list; retiring acts on the list holding it (applyOne, applyAt).
|
|
267
|
+
return target;
|
|
268
|
+
case "at":
|
|
269
|
+
return applyAt(conversion.inner, conversion.path.split("."), target, where, report) as JsonObject;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const applyFold = (conversion: FoldConversion, target: JsonObject, where: string, report: Report): JsonObject => {
|
|
274
|
+
if (!conversion.applies(target)) {
|
|
275
|
+
return target;
|
|
276
|
+
}
|
|
277
|
+
const folded = new Set<string>(conversion.from);
|
|
278
|
+
const fields = Object.fromEntries(Object.entries(target).filter(([key]) => folded.has(key)));
|
|
279
|
+
const entries = Object.entries(target);
|
|
280
|
+
const first = entries.findIndex(([key]) => folded.has(key));
|
|
281
|
+
const kept = entries.filter(([key]) => !folded.has(key) && key !== conversion.into);
|
|
282
|
+
kept.splice(first === -1 ? kept.length : Math.min(first, kept.length), 0, [conversion.into, conversion.convert(fields, target)]);
|
|
283
|
+
report({ conversion: conversion.describe, at: where });
|
|
284
|
+
return Object.fromEntries(kept);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Removes a list's withdrawn entries, by index for an array and by key for an object keyed by id.
|
|
288
|
+
const retireFrom = (conversion: RetireEntriesConversion, container: unknown, where: string, report: Report): unknown => {
|
|
289
|
+
const retired = (entry: unknown, place: string): boolean => {
|
|
290
|
+
if (!conversion.isRetired(entry)) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
report({ conversion: conversion.describe, at: place, detail: glimpse(entry) });
|
|
294
|
+
return true;
|
|
295
|
+
};
|
|
296
|
+
if (Array.isArray(container)) {
|
|
297
|
+
const kept = container.filter((entry, index) => !retired(entry, joinWhere(where, `[${index}]`)));
|
|
298
|
+
return kept.length === container.length ? container : kept;
|
|
299
|
+
}
|
|
300
|
+
if (!isJsonObject(container)) {
|
|
301
|
+
return container;
|
|
302
|
+
}
|
|
303
|
+
const kept = Object.entries(container).filter(([key, entry]) => !retired(entry, joinWhere(where, key)));
|
|
304
|
+
return kept.length === Object.keys(container).length ? container : Object.fromEntries(kept);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const joinWhere = (where: string, segment: string): string => (segment.startsWith("[") || where === "" ? `${where}${segment}` : `${where}.${segment}`);
|
|
308
|
+
|
|
309
|
+
// Every element of an array or every value of an object, rebuilt only where one of them changed.
|
|
310
|
+
const applyToEach = (visit: (value: unknown, where: string) => unknown, container: unknown, where: string): unknown => {
|
|
311
|
+
if (Array.isArray(container)) {
|
|
312
|
+
const next = container.map((value, index) => visit(value, joinWhere(where, `[${index}]`)));
|
|
313
|
+
return next.some((value, index) => value !== container[index]) ? next : container;
|
|
314
|
+
}
|
|
315
|
+
if (!isJsonObject(container)) {
|
|
316
|
+
return container;
|
|
317
|
+
}
|
|
318
|
+
const entries = Object.entries(container);
|
|
319
|
+
const next = entries.map(([key, value]) => [key, visit(value, joinWhere(where, key))] as const);
|
|
320
|
+
return next.some(([, value], index) => value !== entries[index]?.[1]) ? Object.fromEntries(next) : container;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const applyAt = (inner: Conversion, segments: readonly string[], value: unknown, where: string, report: Report): unknown => {
|
|
324
|
+
const [head, ...rest] = segments;
|
|
325
|
+
if (head === undefined) {
|
|
326
|
+
if (inner.kind === "retireEntries") {
|
|
327
|
+
return retireFrom(inner, value, where, report);
|
|
328
|
+
}
|
|
329
|
+
return isJsonObject(value) ? applyToObject(inner, value, where, report) : value;
|
|
330
|
+
}
|
|
331
|
+
if (head === "*") {
|
|
332
|
+
return applyToEach((element, elementWhere) => applyAt(inner, rest, element, elementWhere, report), value, where);
|
|
333
|
+
}
|
|
334
|
+
if (!isJsonObject(value) || !Object.hasOwn(value, head)) {
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
337
|
+
const child = value[head];
|
|
338
|
+
const next = applyAt(inner, rest, child, joinWhere(where, head), report);
|
|
339
|
+
return next === child ? value : { ...value, [head]: next };
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// One conversion over a whole document, at its granularity.
|
|
343
|
+
const applyOne = (conversion: Conversion, granularity: Granularity, value: unknown, report: Report): unknown => {
|
|
344
|
+
if (conversion.kind === "retireEntries" && granularity !== "object") {
|
|
345
|
+
return retireFrom(conversion, value, "", report);
|
|
346
|
+
}
|
|
347
|
+
if (granularity === "object") {
|
|
348
|
+
return isJsonObject(value) ? applyToObject(conversion, value, "", report) : value;
|
|
349
|
+
}
|
|
350
|
+
const container = granularity === "entries" ? Array.isArray(value) : isJsonObject(value);
|
|
351
|
+
return container ? applyToEach((entry, where) => (isJsonObject(entry) ? applyToObject(conversion, entry, where, report) : entry), value, "") : value;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
export class ConversionError extends Error {
|
|
355
|
+
readonly conversion: string;
|
|
356
|
+
|
|
357
|
+
constructor(conversion: string, cause: unknown) {
|
|
358
|
+
super(`conversion "${conversion}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
359
|
+
this.name = "ConversionError";
|
|
360
|
+
this.conversion = conversion;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Every conversion in declaration order; a throw is named after the conversion that threw, not the last that reported.
|
|
365
|
+
const applyAll = (conversions: readonly Conversion[], granularity: Granularity, raw: unknown, report: Report): unknown => {
|
|
366
|
+
let value = raw;
|
|
367
|
+
for (const conversion of conversions) {
|
|
368
|
+
try {
|
|
369
|
+
value = applyOne(conversion, granularity, value, report);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
throw new ConversionError(conversion.describe, error);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return value;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
export interface Converted {
|
|
378
|
+
readonly value: unknown;
|
|
379
|
+
readonly changes: readonly ConversionChange[];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// `checkSettles` runs the list a second time over its own output and fails a conversion that is not a no-op there: what
|
|
383
|
+
// a development build turns on, so the first read that reaches such a conversion fails instead of every read after it
|
|
384
|
+
// quietly rewriting the file.
|
|
385
|
+
export const convertDocument = (conversions: readonly Conversion[], granularity: Granularity, raw: unknown, checkSettles = false): Converted => {
|
|
386
|
+
if (conversions.length === 0) {
|
|
387
|
+
return { value: raw, changes: [] };
|
|
388
|
+
}
|
|
389
|
+
const changes: ConversionChange[] = [];
|
|
390
|
+
const value = applyAll(conversions, granularity, raw, (change) => changes.push(change));
|
|
391
|
+
if (checkSettles && changes.length > 0) {
|
|
392
|
+
const again: ConversionChange[] = [];
|
|
393
|
+
const settled = applyAll(conversions, granularity, value, (change) => again.push(change));
|
|
394
|
+
if (settled !== value || again.length > 0) {
|
|
395
|
+
throw new ConversionError(again[0]?.conversion ?? "unknown", new Error("it changed its own output, so it would run on every read"));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { value, changes };
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// A document's conversions as one-line descriptions, for a plan that has nothing to convert but still names the chain.
|
|
402
|
+
export const describeConversions = (conversions: readonly Conversion[]): string[] => conversions.map((conversion) => conversion.describe);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Stored-document evolution shared by everything that keeps a file across versions: the daemon's stores, and an
|
|
2
|
+
// extension's own files through `sandboxDocument` (@intentic/extension-api). The vocabulary of guarded, pure
|
|
3
|
+
// conversions, and the passthrough that keeps what a build does not know on its writes.
|
|
4
|
+
export * from "./conversions.js";
|
|
5
|
+
export * from "./passthrough.js";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { isJsonObject, type JsonObject } from "./conversions.js";
|
|
2
|
+
|
|
3
|
+
// Carries into a write what the file held that this build's parse dropped: keys a newer build added, and entries this
|
|
4
|
+
// build cannot read. Loose schemas already let an older build READ a newer file; without this, its next write would
|
|
5
|
+
// quietly delete everything it did not understand, so a rollback cost the newer version's data on the first save.
|
|
6
|
+
// What counts as "unknown" is exactly what parse stripped, so a key this build knows and the change removed stays gone,
|
|
7
|
+
// and a key a conversion retired (drop, rename) was removed before parse and is never carried.
|
|
8
|
+
|
|
9
|
+
// A key whose value names which arm of a union an object is: an object that switched arms carries nothing across, since
|
|
10
|
+
// the old arm's extra keys mean nothing on the new one.
|
|
11
|
+
const DISCRIMINATORS = ["kind", "type"] as const;
|
|
12
|
+
|
|
13
|
+
const sameArm = (parsed: JsonObject, updated: JsonObject): boolean =>
|
|
14
|
+
DISCRIMINATORS.every((key) => typeof parsed[key] !== "string" || parsed[key] === updated[key]);
|
|
15
|
+
|
|
16
|
+
// Which keys name an entry, tried in order: most entries carry `id`, a few (workflow runs) their own.
|
|
17
|
+
export type IdKeys = readonly string[];
|
|
18
|
+
const DEFAULT_ID_KEYS: IdKeys = ["id"];
|
|
19
|
+
|
|
20
|
+
const carryObject = (raw: JsonObject, parsed: JsonObject, updated: JsonObject, idKeys: IdKeys): JsonObject => {
|
|
21
|
+
if (!sameArm(parsed, updated)) {
|
|
22
|
+
return updated;
|
|
23
|
+
}
|
|
24
|
+
let result: JsonObject | undefined;
|
|
25
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
26
|
+
if (!Object.hasOwn(updated, key)) {
|
|
27
|
+
// Stripped by parse and not set by the change: a key from a newer build (or a typo, which stays reported).
|
|
28
|
+
if (!Object.hasOwn(parsed, key)) {
|
|
29
|
+
result ??= { ...updated };
|
|
30
|
+
result[key] = value;
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (Object.hasOwn(parsed, key)) {
|
|
35
|
+
const carried = carryUnknown(value, parsed[key], updated[key], idKeys);
|
|
36
|
+
if (carried !== updated[key]) {
|
|
37
|
+
result ??= { ...updated };
|
|
38
|
+
result[key] = carried;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result ?? updated;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const idOf = (value: unknown, idKeys: IdKeys): string | undefined => {
|
|
46
|
+
if (!isJsonObject(value)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const id = idKeys.map((key) => value[key]).find((candidate) => typeof candidate === "string");
|
|
50
|
+
return typeof id === "string" ? id : undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Pairs each written element with the raw element it came from: by reference when the change kept the parsed object,
|
|
54
|
+
// by id when it rebuilt it. Only a parse that kept every element lines raw up with parsed by position.
|
|
55
|
+
const carryArray = (raw: readonly unknown[], parsed: readonly unknown[], updated: readonly unknown[], idKeys: IdKeys): readonly unknown[] => {
|
|
56
|
+
if (raw.length !== parsed.length) {
|
|
57
|
+
return updated;
|
|
58
|
+
}
|
|
59
|
+
const rawOfParsed = new Map<unknown, unknown>();
|
|
60
|
+
const indexOfId = new Map<string, number>();
|
|
61
|
+
parsed.forEach((element, index) => {
|
|
62
|
+
if (typeof element === "object" && element !== null) {
|
|
63
|
+
rawOfParsed.set(element, raw[index]);
|
|
64
|
+
}
|
|
65
|
+
const id = idOf(element, idKeys);
|
|
66
|
+
if (id !== undefined) {
|
|
67
|
+
indexOfId.set(id, index);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
let result: unknown[] | undefined;
|
|
71
|
+
updated.forEach((element, index) => {
|
|
72
|
+
const byId = indexOfId.get(idOf(element, idKeys) ?? "");
|
|
73
|
+
const [source, before] = rawOfParsed.has(element) ? [rawOfParsed.get(element), element] : byId === undefined ? [undefined, undefined] : [raw[byId], parsed[byId]];
|
|
74
|
+
if (source === undefined) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const carried = carryUnknown(source, before, element, idKeys);
|
|
78
|
+
if (carried !== element) {
|
|
79
|
+
result ??= [...updated];
|
|
80
|
+
result[index] = carried;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return result ?? updated;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// `raw` is the file as read (after conversions), `parsed` what the schema made of it, `updated` what the change returns.
|
|
87
|
+
// Answers `updated` itself, by reference, when nothing needs carrying.
|
|
88
|
+
export const carryUnknown = (raw: unknown, parsed: unknown, updated: unknown, idKeys: IdKeys = DEFAULT_ID_KEYS): unknown => {
|
|
89
|
+
if (isJsonObject(raw) && isJsonObject(parsed) && isJsonObject(updated)) {
|
|
90
|
+
return carryObject(raw, parsed, updated, idKeys);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(raw) && Array.isArray(parsed) && Array.isArray(updated)) {
|
|
93
|
+
return carryArray(raw, parsed, updated, idKeys);
|
|
94
|
+
}
|
|
95
|
+
return updated;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// An entry list read one entry at a time: the entries this build can use, the raw entries lined up with them, and the
|
|
99
|
+
// ones it could not read, each at the index it held.
|
|
100
|
+
export interface EntryRead<E> {
|
|
101
|
+
readonly entries: E[];
|
|
102
|
+
readonly aligned: readonly unknown[];
|
|
103
|
+
readonly quarantined: readonly { readonly index: number; readonly entry: unknown }[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const readEntries = <E>(raw: readonly unknown[], parseEntry: (entry: unknown) => E | undefined): EntryRead<E> => {
|
|
107
|
+
const entries: E[] = [];
|
|
108
|
+
const aligned: unknown[] = [];
|
|
109
|
+
const quarantined: { index: number; entry: unknown }[] = [];
|
|
110
|
+
raw.forEach((entry, index) => {
|
|
111
|
+
const parsed = parseEntry(entry);
|
|
112
|
+
if (parsed === undefined) {
|
|
113
|
+
quarantined.push({ index, entry });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
entries.push(parsed);
|
|
117
|
+
aligned.push(entry);
|
|
118
|
+
});
|
|
119
|
+
return { entries, aligned, quarantined };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Puts the entries this build could not read back into what it writes, near where they were; one the write now holds a
|
|
123
|
+
// same-id entry for was replaced on purpose and stays out.
|
|
124
|
+
export const reemitQuarantined = (
|
|
125
|
+
updated: readonly unknown[],
|
|
126
|
+
quarantined: EntryRead<unknown>["quarantined"],
|
|
127
|
+
idKeys: IdKeys = DEFAULT_ID_KEYS,
|
|
128
|
+
): readonly unknown[] => {
|
|
129
|
+
const written = new Set(updated.map((entry) => idOf(entry, idKeys)).filter((id) => id !== undefined));
|
|
130
|
+
const kept = quarantined.filter(({ entry }) => !written.has(idOf(entry, idKeys) ?? "\u0000"));
|
|
131
|
+
if (kept.length === 0) {
|
|
132
|
+
return updated;
|
|
133
|
+
}
|
|
134
|
+
const result = [...updated];
|
|
135
|
+
for (const { index, entry } of kept) {
|
|
136
|
+
result.splice(Math.min(index, result.length), 0, entry);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
};
|
|
@@ -4,6 +4,7 @@ import { LandConflictSchema } from "../schemas/agents.js";
|
|
|
4
4
|
import { ContextTrimSchema } from "../schemas/context-trim.js";
|
|
5
5
|
import { RateLimitInfoSchema } from "../schemas/providers/claude-gate.js";
|
|
6
6
|
import { FastModeStateSchema } from "../schemas/providers/fast-mode.js";
|
|
7
|
+
import { PromptCacheOpeningSchema, PromptFingerprintSchema } from "../schemas/keep-warm.js";
|
|
7
8
|
import { AgentReplySchema, UsageWindowSchema } from "../schemas/providers/plan-limits.js";
|
|
8
9
|
import { SubagentKindSchema, SubagentStatusSchema, SubagentVerificationSchema } from "../schemas/terminal.js";
|
|
9
10
|
import { RetryLadderSchema } from "../schemas/turn-break.js";
|
|
@@ -50,7 +51,8 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
50
51
|
// message to be drawn beside, so this is the only place the reader can learn the turn ran thin.
|
|
51
52
|
ContextTrimSchema.extend({ kind: z.literal("context_trim") }),
|
|
52
53
|
// The SDK's init handshake; carries the model it actually resolved for the turn.
|
|
53
|
-
|
|
54
|
+
// `prompt` names the parts the cache is keyed on, so a later turn can say which of them changed.
|
|
55
|
+
z.object({ kind: z.literal("init"), model: z.string(), prompt: PromptFingerprintSchema.optional() }),
|
|
54
56
|
// The pre-turn snapshot id, emitted once before the provider stream so the client can offer
|
|
55
57
|
// restore-to-before-this-message. Absent on an isolated turn or when the tree was already clean.
|
|
56
58
|
// `index` is the message's position in the transcript, which the rewind route addresses by; absent on a turn with
|
|
@@ -139,7 +141,13 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
139
141
|
cacheCreationTokens: z.number().optional(),
|
|
140
142
|
durationMs: z.number().optional(),
|
|
141
143
|
numTurns: z.number().optional(),
|
|
144
|
+
// The stream's first request only; a sum of frames keeps the first frame's, never adds them.
|
|
145
|
+
openingCacheReadTokens: z.number().optional(),
|
|
146
|
+
openingCacheCreationTokens: z.number().optional(),
|
|
147
|
+
promptFingerprint: z.string().optional(),
|
|
142
148
|
}),
|
|
149
|
+
// What the turn's first request found in the prompt cache, sent as soon as it is known.
|
|
150
|
+
PromptCacheOpeningSchema.extend({ kind: z.literal("prompt_cache") }),
|
|
143
151
|
// The provider's live answer to whether this turn may run, pushed mid-turn; drives the rate-limited notice, not the
|
|
144
152
|
// headroom readouts.
|
|
145
153
|
RateLimitInfoSchema.extend({ kind: z.literal("rate_limit_info"), account: z.string().optional() }),
|
|
@@ -9,3 +9,15 @@ export const landBreakagePrompt = (parts: readonly string[]): string => [LAND_BR
|
|
|
9
9
|
|
|
10
10
|
// Whether a stored prompt is one.
|
|
11
11
|
export const isLandBreakage = (prompt: string): boolean => prompt.startsWith(LAND_BREAKAGE_OPENING);
|
|
12
|
+
|
|
13
|
+
// The opening of a FRESH conversation the daemon starts on a red main-line check that the conversation that landed the
|
|
14
|
+
// work could not take: nobody could be named, several could, or the one named had gone cold or run out of room.
|
|
15
|
+
// Anchored on by the chat like the follow-up above, so it must stay unique and stable across releases.
|
|
16
|
+
export const LAND_FIX_OPENING =
|
|
17
|
+
"The main tree's own check went red after work landed, and no conversation still holding that work could take it, so this one was started to fix it. The failures, the lands they arrived with and where to read those conversations are below. Fix them in this isolated worktree and check the fix as you would any change; your work lands like any other turn.";
|
|
18
|
+
|
|
19
|
+
// The prompt a fresh fix-up is started with: its opening, then the evidence, each part already worded for the model.
|
|
20
|
+
export const landFixPrompt = (parts: readonly string[]): string => [LAND_FIX_OPENING, ...parts].join("\n\n");
|
|
21
|
+
|
|
22
|
+
// Whether a stored prompt is one.
|
|
23
|
+
export const isLandFix = (prompt: string): boolean => prompt.startsWith(LAND_FIX_OPENING);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { withoutResumeNote } from "./resume.js";
|
|
2
|
+
|
|
3
|
+
// The turn the app sends a conversation whose land refused, asking it to rebase and resolve, recognised by its opening.
|
|
4
|
+
// Composed by the editor, but lives here because the fold must recognise it as well: that turn's own rebase notice
|
|
5
|
+
// would otherwise tell the reader the thing the turn was sent to fix.
|
|
6
|
+
|
|
7
|
+
// Anchored on by the chat and the fold, so it must stay unique and stable across releases.
|
|
8
|
+
export const LAND_CONFLICT_OPENING =
|
|
9
|
+
"Landing your work hit a merge conflict, none of it reached the user's workspace; it is all still on your branch. Rebase onto the main line and resolve the conflicts yourself. In each repo below (`root` is your working directory, any other name that subdirectory of it):";
|
|
10
|
+
|
|
11
|
+
// Whether a stored prompt is one. A turn the daemon re-ran carries the same prompt after its resume note.
|
|
12
|
+
export const isLandConflict = (prompt: string): boolean => withoutResumeNote(prompt.trim()).startsWith(LAND_CONFLICT_OPENING);
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { AgentSummarySchema } from "../schemas/agents.js";
|
|
3
3
|
import { AccountUsageSchema, ProviderRefusalSchema } from "../schemas/providers/plan-limits.js";
|
|
4
4
|
import { MemberRoleSchema } from "../schemas/shared.js";
|
|
5
|
-
import { SidecarStatusSchema } from "../schemas/workspace/workspace-tree.js";
|
|
5
|
+
import { SidecarStatusSchema, WorkspaceTreeDeltaSchema } from "../schemas/workspace/workspace-tree.js";
|
|
6
6
|
|
|
7
7
|
// Frames about the sandbox rather than a turn: liveness, boot progress, what moved (repos, refs, running processes,
|
|
8
8
|
// presence, the fleet), and account headroom. One stream carries them all.
|
|
@@ -128,13 +128,19 @@ export type AccountUsageChanged = z.infer<typeof AccountUsageChangedSchema>;
|
|
|
128
128
|
export const ProviderRefusalChangedSchema = z.object({ kind: z.literal("providerRefusal"), provider: z.string(), refusal: ProviderRefusalSchema.optional() });
|
|
129
129
|
export type ProviderRefusalChanged = z.infer<typeof ProviderRefusalChangedSchema>;
|
|
130
130
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
131
|
+
// What moved in the shared tree, once the daemon's resident copy re-listed a batch: a tab holding the generation it
|
|
132
|
+
// counts from patches its tree, and one holding any other fetches it afresh.
|
|
133
|
+
export const TreeChangedSchema = WorkspaceTreeDeltaSchema.extend({ kind: z.literal("treeChanged") });
|
|
134
|
+
export type TreeChanged = z.infer<typeof TreeChangedSchema>;
|
|
135
|
+
|
|
136
|
+
// The /events stream union: hello, heartbeats, boot progress, workspace/repo/ref/runtime/shadow/tree changes, presence
|
|
137
|
+
// and fleet rosters, account headroom and refusal changes. oRPC validates every frame against this.
|
|
133
138
|
export const SystemEventSchema = z.discriminatedUnion("kind", [
|
|
134
139
|
HelloSchema,
|
|
135
140
|
HeartbeatSchema,
|
|
136
141
|
BootSchema,
|
|
137
142
|
WorkspaceChangedSchema,
|
|
143
|
+
TreeChangedSchema,
|
|
138
144
|
DerivedChangedSchema,
|
|
139
145
|
ReposChangedSchema,
|
|
140
146
|
RefsChangedSchema,
|