@ours.network/fleet 0.15.7 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +29 -3
  2. package/dist/application/role-creation-service.d.ts +2 -2
  3. package/dist/config.d.ts +4 -2
  4. package/dist/config.js +3 -2
  5. package/dist/docs.d.ts +1 -1
  6. package/dist/docs.js +24 -4
  7. package/dist/monitor.d.ts +4 -3
  8. package/dist/monitor.js +11 -2
  9. package/dist/owner-channel/channel.js +4 -1
  10. package/dist/runner.js +20 -5
  11. package/dist/session/acp.d.ts +23 -0
  12. package/dist/session/acp.js +183 -10
  13. package/dist/session/arbiter.d.ts +5 -0
  14. package/dist/session/arbiter.js +8 -0
  15. package/dist/session/conversation-normalizer.d.ts +6 -0
  16. package/dist/session/conversation-normalizer.js +4 -3
  17. package/dist/session/conversation-types.d.ts +9 -2
  18. package/dist/session/types.d.ts +13 -1
  19. package/dist/web/fleet-config-service.js +51 -15
  20. package/dist/web/runtime.js +9 -2
  21. package/dist/web/server.d.ts +6 -2
  22. package/dist/web/server.js +50 -0
  23. package/dist/web/topology-draft-store.d.ts +80 -0
  24. package/dist/web/topology-draft-store.js +337 -0
  25. package/dist/web/topology-model.d.ts +62 -0
  26. package/dist/web/topology-model.js +220 -0
  27. package/dist/web/topology-promote.d.ts +31 -0
  28. package/dist/web/topology-promote.js +168 -0
  29. package/dist/web/yaml-document-edit.d.ts +31 -0
  30. package/dist/web/yaml-document-edit.js +401 -0
  31. package/dist/web-app/assets/{TerminalView-BCbgag6j.js → TerminalView-B3rnVWbo.js} +1 -1
  32. package/dist/web-app/assets/index-CliHATFt.js +10 -0
  33. package/dist/web-app/assets/{index-COg4Azq1.css → index-DuC-xnX4.css} +1 -1
  34. package/dist/web-app/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web-app/assets/index-BstfCP_F.js +0 -10
@@ -0,0 +1,220 @@
1
+ import { deriveTopology, } from './topology.js';
2
+ export const IMPLICIT_WATCH_LABEL = 'watches all agents (default)';
3
+ const AGENT_MISSION = {
4
+ field: 'mission',
5
+ why: 'An agent without a mission has nothing to do and reads as noise on the canvas.',
6
+ fix: 'Write one sentence about this agent\'s job.',
7
+ };
8
+ const WATCHDOG_COORDINATOR = {
9
+ field: 'coordinator',
10
+ why: 'A watchdog reports to exactly one coordinator; the fleet refuses to load without it.',
11
+ fix: 'Name the agent this watchdog reports to.',
12
+ };
13
+ const LOOP_PROMPT = {
14
+ field: 'prompt',
15
+ why: 'An interval delivers a prompt on a schedule; there is nothing to deliver yet.',
16
+ fix: 'Write the prompt this interval should send.',
17
+ };
18
+ const LOOP_TARGET = {
19
+ field: 'roles',
20
+ why: 'An interval needs at least one agent to deliver its prompt to.',
21
+ fix: 'Connect this interval to an agent.',
22
+ };
23
+ /**
24
+ * Merge configuration and drafts into the graph the console renders.
25
+ *
26
+ * Drift is surfaced, never silently repaired: a draft whose name is now taken
27
+ * by real configuration is reported as a problem rather than shadowing it, a
28
+ * draft edge to a vanished node is kept and marked dangling, and a position for
29
+ * a node that no longer exists is simply dropped.
30
+ */
31
+ export function mergeTopology(config, roles, draft) {
32
+ const snapshot = deriveTopology(config, roles);
33
+ const problems = draft.problem ? [draft.problem] : [];
34
+ const configIds = new Set(snapshot.nodes.map(node => node.id));
35
+ const shadowed = draft.draft.drafts.nodes.filter(node => configIds.has(node.id));
36
+ if (shadowed.length)
37
+ problems.push({
38
+ code: 'draft_conflicts_with_config',
39
+ severity: 'warning',
40
+ source: 'topology.json',
41
+ detail: `${shadowed.map(node => node.id).join(', ')} now exist${shadowed.length === 1 ? 's' : ''} in the fleet configuration; rename the draft to keep sketching it`,
42
+ });
43
+ const draftNodes = draft.draft.drafts.nodes.filter(node => !configIds.has(node.id));
44
+ const draftEdges = draft.draft.drafts.edges;
45
+ const nodes = [
46
+ ...snapshot.nodes.map(node => configNode(node, config)),
47
+ ...draftNodes.map(node => draftNode(node, draftEdges, draftNodes, config)),
48
+ ];
49
+ const known = new Map(nodes.map(node => [node.id, node]));
50
+ const implicitConfigWatchdogs = new Set(config.watchdogs.filter(watchdog => !watchdog.watchExplicit).map(watchdog => `watchdog:${watchdog.name}`));
51
+ const edges = [
52
+ ...snapshot.edges.map(edge => annotate(edge, 'config', edge.kind === 'watches' && implicitConfigWatchdogs.has(edge.from), known)),
53
+ ...draftEdges.map(edge => annotate({
54
+ id: `${edge.kind}:${edge.from}:${edge.to}`, kind: edge.kind,
55
+ from: edge.from, to: edge.to, label: edgeLabel(edge.kind),
56
+ }, 'draft', false, known)),
57
+ ...implicitDraftWatchEdges(draftNodes, draftEdges, nodes, known),
58
+ ];
59
+ for (const node of nodes) {
60
+ const position = draft.draft.positions[node.id];
61
+ if (position)
62
+ node.position = position;
63
+ }
64
+ return {
65
+ nodes,
66
+ edges,
67
+ unknownLineage: snapshot.unknownLineage,
68
+ problems,
69
+ draftRevision: draft.revision,
70
+ draftWritable: draft.writable,
71
+ };
72
+ }
73
+ /* ------------------------------------------------------------------ *
74
+ * Configured nodes
75
+ * ------------------------------------------------------------------ */
76
+ function configNode(node, config) {
77
+ const name = node.id.slice(node.kind.length + 1);
78
+ const missing = [];
79
+ let enabled;
80
+ if (node.kind === 'agent') {
81
+ const role = config.roles.find(candidate => candidate.name === name);
82
+ // A role reachable only through its on-disk state directory is running
83
+ // configuration we cannot inspect; do not invent a gap for it.
84
+ if (role && !nonBlank(role.mission))
85
+ missing.push(AGENT_MISSION);
86
+ }
87
+ else if (node.kind === 'watchdog') {
88
+ enabled = config.watchdogs.find(candidate => candidate.name === name)?.enabled;
89
+ }
90
+ else {
91
+ enabled = config.loops.find(candidate => candidate.name === name)?.enabled;
92
+ }
93
+ const complete = missing.length === 0;
94
+ return { ...node, origin: 'config', valid: true, complete, launchable: complete, missing, enabled };
95
+ }
96
+ /* ------------------------------------------------------------------ *
97
+ * Draft nodes
98
+ * ------------------------------------------------------------------ */
99
+ function draftNode(node, edges, draftNodes, config) {
100
+ const name = node.id.slice(node.kind.length + 1);
101
+ const missing = [];
102
+ const collision = nameConflict(node.kind, name, config);
103
+ if (collision)
104
+ missing.push({
105
+ field: 'name',
106
+ why: `The fleet already uses the name "${name}" for ${collision}.`,
107
+ fix: 'Rename this draft before adding it to the fleet.',
108
+ });
109
+ const draftIds = new Set(draftNodes.map(other => other.id));
110
+ const outgoing = edges.filter(edge => edge.from === node.id);
111
+ const stillDraft = (kind) => outgoing
112
+ .filter(edge => edge.kind === kind && draftIds.has(edge.to))
113
+ .map(edge => edge.to.slice('agent:'.length));
114
+ if (node.kind === 'agent') {
115
+ if (!nonBlank(node.fields.mission))
116
+ missing.push(AGENT_MISSION);
117
+ }
118
+ else if (node.kind === 'watchdog') {
119
+ if (!nonBlank(node.fields.coordinator))
120
+ missing.push(WATCHDOG_COORDINATOR);
121
+ pushPendingTargets(missing, 'watch', stillDraft('watches'), 'A watchdog may only watch agents that are already in the fleet.');
122
+ }
123
+ else {
124
+ if (!outgoing.some(edge => edge.kind === 'targets'))
125
+ missing.push(LOOP_TARGET);
126
+ if (!nonBlank(node.fields.prompt))
127
+ missing.push(LOOP_PROMPT);
128
+ pushPendingTargets(missing, 'roles', stillDraft('targets'), 'An interval may only deliver to agents that are already in the fleet.');
129
+ }
130
+ return {
131
+ id: node.id,
132
+ kind: node.kind,
133
+ label: name,
134
+ status: 'draft',
135
+ detail: draftDetail(node),
136
+ origin: 'draft',
137
+ valid: !collision,
138
+ complete: missing.length === 0,
139
+ launchable: false, // a draft is in no file the supervisor reads
140
+ missing,
141
+ fields: node.fields,
142
+ enabled: node.fields.enabled === undefined ? undefined : node.fields.enabled !== false,
143
+ };
144
+ }
145
+ function pushPendingTargets(missing, field, pending, why) {
146
+ if (!pending.length)
147
+ return;
148
+ missing.push({
149
+ field, why,
150
+ fix: `Add ${pending.join(', ')} to the fleet first, or remove the connection.`,
151
+ });
152
+ }
153
+ function draftDetail(node) {
154
+ const value = node.kind === 'agent' ? node.fields.mission
155
+ : node.kind === 'watchdog' ? node.fields.coordinator && `Reports to ${node.fields.coordinator}`
156
+ : node.fields.prompt;
157
+ return nonBlank(value) ? String(value) : undefined;
158
+ }
159
+ /* ------------------------------------------------------------------ *
160
+ * Edges
161
+ * ------------------------------------------------------------------ */
162
+ function annotate(edge, origin, implicit, known) {
163
+ return {
164
+ ...edge,
165
+ label: implicit ? IMPLICIT_WATCH_LABEL : edge.label,
166
+ origin,
167
+ implicit,
168
+ dangling: !known.has(edge.from) || !known.has(edge.to),
169
+ };
170
+ }
171
+ /**
172
+ * A draft watchdog nobody scoped to a single agent is a *standalone* watchdog:
173
+ * promoting it omits `watch:`, which the config layer reads as "every role".
174
+ * Draw that so the owner sees the coverage before adding it, and so an agent
175
+ * added later is visibly covered with no edit at all.
176
+ */
177
+ function implicitDraftWatchEdges(draftNodes, draftEdges, nodes, known) {
178
+ const persistentAgents = nodes.filter(node => node.kind === 'agent' && node.lifetime !== 'temporary');
179
+ return draftNodes
180
+ .filter(node => node.kind === 'watchdog'
181
+ && !draftEdges.some(edge => edge.kind === 'watches' && edge.from === node.id))
182
+ .flatMap(watchdog => persistentAgents.map(agent => annotate({
183
+ id: `watches:${watchdog.id}:${agent.id}`, kind: 'watches',
184
+ from: watchdog.id, to: agent.id, label: IMPLICIT_WATCH_LABEL,
185
+ }, 'draft', true, known)));
186
+ }
187
+ const edgeLabel = (kind) => (kind === 'watches' ? 'watches' : kind === 'targets' ? 'delivers to' : 'oversees');
188
+ /* ------------------------------------------------------------------ *
189
+ * Shared
190
+ * ------------------------------------------------------------------ */
191
+ /**
192
+ * What already owns `name`, or undefined when a draft of this kind may use it.
193
+ *
194
+ * Deliberately mirrors the collisions the config layer actually rejects and no
195
+ * others: a watchdog name may not equal a role name, and a watchdog identity
196
+ * (`Watchdog-<name>` by default) may not equal a role name, a role identity or
197
+ * another watchdog identity. Loop names share no namespace with anything, so a
198
+ * loop is only ever blocked by another loop of the same name.
199
+ */
200
+ function nameConflict(kind, name, config) {
201
+ if (kind === 'loop')
202
+ return undefined;
203
+ if (kind === 'agent') {
204
+ // Adding a role named N breaks any watchdog already using N as its identity.
205
+ if (config.watchdogs.some(watchdog => watchdog.name === name))
206
+ return 'a watchdog';
207
+ const identity = config.watchdogs.find(watchdog => watchdog.identity === name);
208
+ return identity ? `watchdog ${identity.name}'s identity` : undefined;
209
+ }
210
+ if (config.roles.some(role => role.name === name))
211
+ return 'a role';
212
+ const identity = `Watchdog-${name}`;
213
+ if (config.roles.some(role => role.name === identity))
214
+ return `a role, which this watchdog's identity "${identity}" would collide with`;
215
+ if (config.roles.some(role => role.identity === identity))
216
+ return `a role identity, which this watchdog's identity "${identity}" would collide with`;
217
+ const other = config.watchdogs.find(watchdog => watchdog.identity === identity);
218
+ return other ? `watchdog ${other.name}'s identity "${identity}"` : undefined;
219
+ }
220
+ const nonBlank = (value) => typeof value === 'string' ? value.trim() !== '' : value !== undefined && value !== null;
@@ -0,0 +1,31 @@
1
+ import type { ConfigPreviewResult, ConfigWriteResult, FleetConfigService } from './fleet-config-service.js';
2
+ import type { TopologyDraftStore } from './topology-draft-store.js';
3
+ import type { MergedTopology } from './topology-model.js';
4
+ export interface PromoteRequest {
5
+ ids: string[];
6
+ configRevision: string;
7
+ draftRevision?: string;
8
+ }
9
+ export interface PromotePreview extends ConfigPreviewResult {
10
+ promoted: string[];
11
+ }
12
+ export interface PromoteResult extends ConfigWriteResult {
13
+ promoted: string[];
14
+ /** False when the config landed but the sketches could not be cleared. */
15
+ draftsCleared: boolean;
16
+ draftRevision: string;
17
+ }
18
+ export interface TopologyPromoteOptions {
19
+ drafts: TopologyDraftStore;
20
+ configuration: FleetConfigService;
21
+ topology(): Promise<MergedTopology>;
22
+ }
23
+ export declare class TopologyPromoteService {
24
+ private readonly options;
25
+ constructor(options: TopologyPromoteOptions);
26
+ preview(request: PromoteRequest): Promise<PromotePreview>;
27
+ promote(request: PromoteRequest): Promise<PromoteResult>;
28
+ private clearDrafts;
29
+ /** Build the configuration model that adding `ids` produces. */
30
+ private build;
31
+ }
@@ -0,0 +1,168 @@
1
+ import { FleetError } from '../application/errors.js';
2
+ /**
3
+ * Turn sketches into configuration.
4
+ *
5
+ * Promotion is the middle step of sketch -> promote -> launch, and it writes
6
+ * configuration ONLY. It never provisions an identity, never registers a
7
+ * supervisor service and never starts a process: `Launch` is a separate, explicit
8
+ * action. That separation is what makes "visible but not launchable" true at the
9
+ * daemon level rather than as a UI rule.
10
+ *
11
+ * No YAML is written here. The mutation is expressed against the configuration
12
+ * *model* and handed to `FleetConfigService`, so the revision guard, the real
13
+ * loader validating a candidate file, the reviewed diff, the timestamped backup
14
+ * and the atomic 0600 replace are all impossible to bypass.
15
+ */
16
+ /** Loop interval when the sketch did not choose one (`resolveLoops` requires it). */
17
+ const DEFAULT_LOOP_INTERVAL = '10m';
18
+ /** Scalar draft fields that are valid keys of a role mapping. */
19
+ const ROLE_FIELDS = ['mission', 'bio', 'persona', 'coordinator', 'harness', 'session', 'model', 'identity', 'cwd'];
20
+ /** Scalar draft fields that are valid keys of a watchdog mapping. */
21
+ const WATCHDOG_FIELDS = ['coordinator', 'interval', 'enabled'];
22
+ /** Scalar draft fields that are valid keys of a loop mapping. */
23
+ const LOOP_FIELDS = ['prompt', 'interval', 'initial_delay', 'jitter'];
24
+ export class TopologyPromoteService {
25
+ options;
26
+ constructor(options) {
27
+ this.options = options;
28
+ }
29
+ async preview(request) {
30
+ const { model, promoted } = await this.build(request);
31
+ const preview = await this.options.configuration.preview(request.configRevision, model);
32
+ return { ...preview, promoted };
33
+ }
34
+ async promote(request) {
35
+ const { model, promoted } = await this.build(request);
36
+ const written = await this.options.configuration.write(request.configRevision, model);
37
+ // The configuration is real now. Clearing the sketches is best effort: if the
38
+ // sidecar moved under us the drafts simply resurface as shadowed-by-config,
39
+ // which the merged model already reports, rather than losing the write.
40
+ const cleared = await this.clearDrafts(promoted, request.draftRevision);
41
+ return { ...written, promoted, draftsCleared: cleared.ok, draftRevision: cleared.revision };
42
+ }
43
+ async clearDrafts(promoted, draftRevision) {
44
+ const current = this.options.drafts.read();
45
+ if (draftRevision !== undefined && draftRevision !== current.revision)
46
+ return { ok: false, revision: current.revision };
47
+ const promotedIds = new Set(promoted);
48
+ const next = {
49
+ ...current.draft,
50
+ drafts: {
51
+ nodes: current.draft.drafts.nodes.filter(node => !promotedIds.has(node.id)),
52
+ /*
53
+ * A drawn edge is owned by its SOURCE — it becomes the source's `watch:`
54
+ * or `roles:` list — so it is materialised, and therefore redundant, only
55
+ * once the source itself is written. Clearing it because the TARGET was
56
+ * promoted silently rewrites the survivor's meaning: a watchdog scoped to
57
+ * one agent loses its only scope edge, reads as standalone, and is then
58
+ * written with no `watch:` key at all — that is, watching everything.
59
+ */
60
+ edges: current.draft.drafts.edges.filter(edge => !promotedIds.has(edge.from)),
61
+ },
62
+ };
63
+ try {
64
+ const written = await this.options.drafts.write(current.revision, next);
65
+ return { ok: true, revision: written.revision };
66
+ }
67
+ catch {
68
+ return { ok: false, revision: current.revision };
69
+ }
70
+ }
71
+ /** Build the configuration model that adding `ids` produces. */
72
+ async build(request) {
73
+ if (!Array.isArray(request.ids) || request.ids.length === 0)
74
+ throw new FleetError('invalid_request', 'name at least one sketch to add to the fleet');
75
+ const merged = await this.options.topology();
76
+ const read = this.options.configuration.read();
77
+ if (read.revision !== request.configRevision)
78
+ throw new FleetError('stale_state', 'fleet.yaml changed since it was opened; reload before adding to the fleet');
79
+ const selected = request.ids.map(id => resolve(merged, id));
80
+ const model = structuredClone(read.model);
81
+ // Agents first: a watchdog's `watch:` list and a loop's `roles:` list may only
82
+ // name roles that exist, including ones being added in this same write.
83
+ for (const node of [...selected].sort(byKind))
84
+ addNode(model, node, merged);
85
+ return { model, promoted: selected.map(node => node.id) };
86
+ }
87
+ }
88
+ const KIND_ORDER = { agent: 0, watchdog: 1, loop: 2 };
89
+ const byKind = (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
90
+ function resolve(merged, id) {
91
+ const node = merged.nodes.find(candidate => candidate.id === id);
92
+ if (!node)
93
+ throw new FleetError('invalid_request', `there is no sketch called ${id}`);
94
+ if (node.origin !== 'draft')
95
+ throw new FleetError('conflict', `${node.label} is already part of the fleet configuration`);
96
+ if (!node.valid || !node.complete) {
97
+ const why = node.missing.map(item => `${item.field}: ${item.fix}`).join(' ');
98
+ throw new FleetError('invalid_request', `${node.label} is not ready to add — ${why}`);
99
+ }
100
+ return node;
101
+ }
102
+ function addNode(model, node, merged) {
103
+ const block = section(model, node.kind === 'agent' ? 'roles' : node.kind === 'watchdog' ? 'watchdogs' : 'loops');
104
+ if (block[node.label] !== undefined)
105
+ throw new FleetError('conflict', `${node.label} already exists in the configuration`);
106
+ block[node.label] = node.kind === 'agent' ? agentEntry(node)
107
+ : node.kind === 'watchdog' ? watchdogEntry(node, merged)
108
+ : loopEntry(node, merged, model);
109
+ }
110
+ function agentEntry(node) {
111
+ return pick(node, ROLE_FIELDS);
112
+ }
113
+ /**
114
+ * D4, and the owner requirement it encodes: a watchdog drawn on its own carries
115
+ * NO `watch:` key, which the config layer reads as "every configured role" —
116
+ * so an agent added tomorrow is covered with no edit at all. A watchdog created
117
+ * from a specific agent carries that agent explicitly and stays scoped to it.
118
+ */
119
+ function watchdogEntry(node, merged) {
120
+ const scoped = linked(merged, node.id, 'watches');
121
+ return { ...pick(node, WATCHDOG_FIELDS), ...(scoped.length ? { watch: scoped } : {}) };
122
+ }
123
+ function loopEntry(node, merged, model) {
124
+ const roles = linked(merged, node.id, 'targets');
125
+ const entry = {
126
+ roles, ...pick(node, LOOP_FIELDS),
127
+ };
128
+ entry.interval ??= DEFAULT_LOOP_INTERVAL;
129
+ // An enabled loop hard-requires `session: acp` on every target. Adding one to a
130
+ // tmux agent would make the whole fleet unloadable, so it arrives switched off
131
+ // and badged instead — the owner turns it on after switching the agent to ACP.
132
+ if (node.enabled === false || !roles.every(role => sessionOf(model, role) === 'acp'))
133
+ entry.enabled = false;
134
+ return entry;
135
+ }
136
+ /** Names of the agents this node points at, from drawn edges and derived ones alike. */
137
+ function linked(merged, id, kind) {
138
+ return [...new Set(merged.edges
139
+ .filter(edge => edge.kind === kind && edge.from === id && !edge.implicit && !edge.dangling)
140
+ .map(edge => edge.to.slice('agent:'.length)))].sort();
141
+ }
142
+ function sessionOf(model, role) {
143
+ const roles = model.roles;
144
+ const defaults = model.defaults;
145
+ return String(roles?.[role]?.session ?? defaults?.session ?? 'tmux');
146
+ }
147
+ function section(model, key) {
148
+ const existing = model[key];
149
+ if (existing && typeof existing === 'object' && !Array.isArray(existing))
150
+ return existing;
151
+ const created = {};
152
+ model[key] = created;
153
+ return created;
154
+ }
155
+ /** Copy only the sketched fields that are real keys of the target mapping. */
156
+ function pick(node, keys) {
157
+ const fields = node.fields ?? {};
158
+ const out = {};
159
+ for (const key of keys) {
160
+ const value = fields[key];
161
+ if (value === undefined)
162
+ continue;
163
+ if (typeof value === 'string' && value.trim() === '')
164
+ continue;
165
+ out[key] = value;
166
+ }
167
+ return out;
168
+ }
@@ -0,0 +1,31 @@
1
+ export type YamlModel = Record<string, unknown>;
2
+ /** Formatting of the source document, so a rewrite does not reflow it. */
3
+ export interface DocumentFormatting {
4
+ indent: number;
5
+ indentSeq: boolean;
6
+ }
7
+ /**
8
+ * Apply `model` to `source`, returning the new document text.
9
+ *
10
+ * Guarantees:
11
+ * - an unchanged model returns `source` byte for byte;
12
+ * - regions outside a change keep their bytes whenever the change is spliceable;
13
+ * - the result parses back to exactly `model`, verified before returning, so a
14
+ * planning bug can never silently write something else to disk.
15
+ */
16
+ export declare function renderModelOntoSource(source: string, model: YamlModel): string;
17
+ /**
18
+ * Replace every `defaults.env` / `roles.*.env` value — and every `vars:` entry
19
+ * those values interpolate — with `marker`.
20
+ *
21
+ * Splices the scalars out of the original bytes rather than re-rendering, so the
22
+ * only difference from `source` is the secrets themselves. Redaction must not be
23
+ * able to introduce (or conceal) a formatting change in a review diff.
24
+ */
25
+ export declare function redactSourceSecrets(source: string, marker: string): string;
26
+ /**
27
+ * Best-effort detection of the document's block indentation, used only when a
28
+ * change is not spliceable and the document has to be re-rendered. Detection
29
+ * failure falls back to the library defaults; correctness never depends on it.
30
+ */
31
+ export declare function detectFormatting(source: string): DocumentFormatting;