@abraca/orchestrator 2.3.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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * TimelineRunner — schedules and executes timed actions with drift correction.
3
+ */
4
+ import type { TimelineEntry } from './types.ts'
5
+ import type { ActionExecutor } from './actions/index.ts'
6
+ import { sleep, log } from './utils.ts'
7
+
8
+ export type ErrorStrategy = 'log' | 'abort'
9
+
10
+ export interface TimelineRunnerOptions {
11
+ /** How to handle action errors. 'log' continues, 'abort' stops. Default: 'log'. */
12
+ onError?: ErrorStrategy
13
+ }
14
+
15
+ export class TimelineRunner {
16
+ private sceneStartTime = 0
17
+ private options: TimelineRunnerOptions
18
+ readonly errors: { entry: TimelineEntry; error: Error; elapsed: number }[] = []
19
+
20
+ constructor(options?: TimelineRunnerOptions) {
21
+ this.options = options ?? {}
22
+ }
23
+
24
+ /** Get elapsed ms since scene start. */
25
+ get elapsed(): number {
26
+ return Date.now() - this.sceneStartTime
27
+ }
28
+
29
+ /** Execute all timeline entries in order with proper timing. */
30
+ async run(timeline: TimelineEntry[], execute: ActionExecutor): Promise<void> {
31
+ const sorted = [...timeline].sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
32
+ const errorStrategy = this.options.onError ?? 'log'
33
+
34
+ this.sceneStartTime = Date.now()
35
+ const pending: Promise<void>[] = []
36
+
37
+ for (let i = 0; i < sorted.length; i++) {
38
+ const entry = sorted[i]!
39
+ const targetTime = entry.at ?? 0
40
+ const elapsed = Date.now() - this.sceneStartTime
41
+ const delay = targetTime - elapsed
42
+
43
+ if (delay > 0) {
44
+ await sleep(delay)
45
+ }
46
+
47
+ // Log progress
48
+ const ts = `${((Date.now() - this.sceneStartTime) / 1000).toFixed(1)}s`
49
+ const actorLabel = entry.actor ?? '—'
50
+ log(`[${ts}] ${actorLabel}: ${entry.action.type}`)
51
+
52
+ const actionPromise = execute(entry).catch((err: Error) => {
53
+ const errorInfo = {
54
+ entry,
55
+ error: err,
56
+ elapsed: Date.now() - this.sceneStartTime,
57
+ }
58
+ this.errors.push(errorInfo)
59
+ log(`Error in ${entry.action.type}${entry.actor ? ` for ${entry.actor}` : ''}: ${err.message}`)
60
+ if (errorStrategy === 'abort') {
61
+ throw err
62
+ }
63
+ })
64
+
65
+ // Check if the next entry has the same timestamp — if so, run in parallel
66
+ const nextEntry = sorted[i + 1]
67
+ if (nextEntry && (nextEntry.at ?? 0) === targetTime) {
68
+ pending.push(actionPromise)
69
+ } else {
70
+ pending.push(actionPromise)
71
+ await Promise.all(pending)
72
+ pending.length = 0
73
+ }
74
+ }
75
+
76
+ if (pending.length) {
77
+ await Promise.all(pending)
78
+ }
79
+
80
+ if (this.errors.length) {
81
+ log(`Timeline finished with ${this.errors.length} error(s)`)
82
+ }
83
+ }
84
+ }
package/src/types.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Type definitions for the orchestrator scene format.
3
+ */
4
+
5
+ // ── Server & Actor ──────────────────────────────────────────────────────────
6
+
7
+ export interface ServerConfig {
8
+ url: string
9
+ inviteCode?: string
10
+ }
11
+
12
+ export interface ActorDef {
13
+ name: string
14
+ color: string
15
+ keyFile?: string
16
+ avatar?: string
17
+ }
18
+
19
+ // ── Easing ──────────────────────────────────────────────────────────────────
20
+
21
+ export type EasingFn = (t: number) => number
22
+ export type EasingName = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
23
+
24
+ // ── Actions ─────────────────────────────────────────────────────────────────
25
+
26
+ export interface ConnectAction { type: 'connect' }
27
+ export interface DisconnectAction { type: 'disconnect' }
28
+
29
+ export interface NavigateAction {
30
+ type: 'navigate'
31
+ docId: string
32
+ }
33
+
34
+ export interface TypeAction {
35
+ type: 'type'
36
+ docId: string
37
+ text: string
38
+ /** ms per character (default 80) */
39
+ speed?: number
40
+ /** random variance +/- ms (default 30) */
41
+ variance?: number
42
+ /** character index to start typing at (default: end of doc) */
43
+ position?: number
44
+ }
45
+
46
+ export interface SelectAction {
47
+ type: 'select'
48
+ docId: string
49
+ anchor: number
50
+ head: number
51
+ }
52
+
53
+ export interface MoveCursorAction {
54
+ type: 'moveCursor'
55
+ docId: string
56
+ from: number
57
+ to: number
58
+ duration: number
59
+ easing?: EasingName
60
+ }
61
+
62
+ export interface SetStatusAction {
63
+ type: 'setStatus'
64
+ status: string | null
65
+ }
66
+
67
+ export interface SetAwarenessAction {
68
+ type: 'setAwareness'
69
+ /** If set, applies to child provider; otherwise root */
70
+ docId?: string
71
+ fields: Record<string, unknown>
72
+ }
73
+
74
+ export interface ClearAwarenessAction {
75
+ type: 'clearAwareness'
76
+ docId?: string
77
+ fields: string[]
78
+ }
79
+
80
+ export interface CreateDocumentAction {
81
+ type: 'createDocument'
82
+ parentId: string
83
+ label: string
84
+ docType?: string
85
+ meta?: Record<string, unknown>
86
+ /** Store created doc ID under this key in vars for later reference */
87
+ assignId?: string
88
+ }
89
+
90
+ export interface MoveDocumentAction {
91
+ type: 'moveDocument'
92
+ docId: string
93
+ newParentId: string
94
+ order?: number
95
+ }
96
+
97
+ export interface WriteContentAction {
98
+ type: 'writeContent'
99
+ docId: string
100
+ markdown: string
101
+ }
102
+
103
+ export interface DeleteContentAction {
104
+ type: 'deleteContent'
105
+ docId: string
106
+ from: number
107
+ length: number
108
+ }
109
+
110
+ export interface SetMetaAction {
111
+ type: 'setMeta'
112
+ docId: string
113
+ meta: Record<string, unknown>
114
+ }
115
+
116
+ export interface PointerMoveAction {
117
+ type: 'pointerMove'
118
+ docId: string
119
+ from: { x: number; y: number }
120
+ to: { x: number; y: number }
121
+ duration: number
122
+ easing?: EasingName
123
+ }
124
+
125
+ export interface ScrollToAction {
126
+ type: 'scrollTo'
127
+ docId: string
128
+ /** 0-1 normalized scroll position */
129
+ position: number
130
+ }
131
+
132
+ export interface KanbanHoverAction {
133
+ type: 'kanbanHover'
134
+ docId: string
135
+ cardId: string | null
136
+ }
137
+
138
+ export interface KanbanDragAction {
139
+ type: 'kanbanDrag'
140
+ docId: string
141
+ cardId: string
142
+ toColumnId: string
143
+ duration: number
144
+ }
145
+
146
+ export interface RenameDocumentAction {
147
+ type: 'renameDocument'
148
+ docId: string
149
+ label: string
150
+ }
151
+
152
+ export interface SendChatAction {
153
+ type: 'sendChat'
154
+ /** Channel key (e.g. 'group:docId' or 'dm:key1:key2') */
155
+ channel: string
156
+ message: string
157
+ }
158
+
159
+ export interface TypeDeleteAction {
160
+ type: 'typeDelete'
161
+ docId: string
162
+ /** Number of characters to delete (backspace) */
163
+ count: number
164
+ /** ms per deletion (default 60) */
165
+ speed?: number
166
+ /** random variance +/- ms (default 20) */
167
+ variance?: number
168
+ /** character index to start deleting from (default: end of doc) */
169
+ position?: number
170
+ }
171
+
172
+ export interface RepeatAction {
173
+ type: 'repeat'
174
+ /** Number of times to repeat */
175
+ times: number
176
+ actions: TimelineEntry[]
177
+ }
178
+
179
+ export interface WaitAction {
180
+ type: 'wait'
181
+ duration: number
182
+ }
183
+
184
+ export interface ParallelAction {
185
+ type: 'parallel'
186
+ actions: TimelineEntry[]
187
+ }
188
+
189
+ export interface SequenceAction {
190
+ type: 'sequence'
191
+ actions: TimelineEntry[]
192
+ }
193
+
194
+ export type Action =
195
+ | ConnectAction
196
+ | DisconnectAction
197
+ | NavigateAction
198
+ | TypeAction
199
+ | TypeDeleteAction
200
+ | SelectAction
201
+ | MoveCursorAction
202
+ | SetStatusAction
203
+ | SetAwarenessAction
204
+ | ClearAwarenessAction
205
+ | CreateDocumentAction
206
+ | MoveDocumentAction
207
+ | RenameDocumentAction
208
+ | WriteContentAction
209
+ | DeleteContentAction
210
+ | SetMetaAction
211
+ | PointerMoveAction
212
+ | ScrollToAction
213
+ | KanbanHoverAction
214
+ | KanbanDragAction
215
+ | SendChatAction
216
+ | WaitAction
217
+ | ParallelAction
218
+ | SequenceAction
219
+ | RepeatAction
220
+
221
+ // ── Timeline ────────────────────────────────────────────────────────────────
222
+
223
+ export interface TimelineEntry {
224
+ /** ms from scene start (or parent start for nested entries) */
225
+ at?: number
226
+ /** Which actor performs this action */
227
+ actor?: string
228
+ action: Action
229
+ }
230
+
231
+ // ── Scene ───────────────────────────────────────────────────────────────────
232
+
233
+ export interface Scene {
234
+ server: ServerConfig
235
+ actors: ActorDef[]
236
+ timeline: TimelineEntry[]
237
+ /** Pre-defined variables (e.g. known doc IDs) */
238
+ vars?: Record<string, string>
239
+ /** Max scene duration in ms. Auto-cleanup after this time. */
240
+ duration?: number
241
+ /** Called after all actors are prepared but before timeline runs. */
242
+ onStart?: () => Promise<void> | void
243
+ /** Called after timeline completes (before cleanup). */
244
+ onEnd?: () => Promise<void> | void
245
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Utility functions for the orchestrator.
3
+ */
4
+
5
+ /** Wait for a provider's `synced` event with a timeout. Resolves immediately if already synced. */
6
+ export function waitForSync(
7
+ provider: { on(event: string, cb: () => void): void; off(event: string, cb: () => void): void; synced?: boolean },
8
+ timeoutMs = 15000
9
+ ): Promise<void> {
10
+ // If already synced, resolve immediately
11
+ if (provider.synced) return Promise.resolve()
12
+
13
+ return new Promise<void>((resolve, reject) => {
14
+ const timer = setTimeout(() => {
15
+ provider.off('synced', handler)
16
+ reject(new Error(`Sync timed out after ${timeoutMs}ms`))
17
+ }, timeoutMs)
18
+
19
+ function handler() {
20
+ clearTimeout(timer)
21
+ resolve()
22
+ }
23
+
24
+ provider.on('synced', handler)
25
+ })
26
+ }
27
+
28
+ /** Sleep for a given number of milliseconds. */
29
+ export function sleep(ms: number): Promise<void> {
30
+ return new Promise(resolve => setTimeout(resolve, ms))
31
+ }
32
+
33
+ /** Wraps a promise with a timeout. */
34
+ export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message?: string): Promise<T> {
35
+ return new Promise<T>((resolve, reject) => {
36
+ const timer = setTimeout(
37
+ () => reject(new Error(message ?? `Operation timed out after ${timeoutMs}ms`)),
38
+ timeoutMs
39
+ )
40
+ promise.then(
41
+ (val) => { clearTimeout(timer); resolve(val) },
42
+ (err) => { clearTimeout(timer); reject(err) }
43
+ )
44
+ })
45
+ }
46
+
47
+ /** Log to stderr with [orchestrator] prefix. */
48
+ export function log(msg: string): void {
49
+ console.error(`[orchestrator] ${msg}`)
50
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Y.js document structure utilities for TipTap-compatible editing.
3
+ *
4
+ * TipTap documents have the structure:
5
+ * XmlFragment('default') → [documentHeader, documentMeta, ...body paragraphs]
6
+ *
7
+ * All text position functions skip documentHeader and documentMeta —
8
+ * character index 0 maps to the first character of the first body paragraph.
9
+ */
10
+ import * as Y from 'yjs'
11
+
12
+ const SCHEMA_NODES = new Set(['documentHeader', 'documentMeta'])
13
+
14
+ /** Check if an XmlElement is a TipTap schema node (not body content). */
15
+ function isSchemaNode(el: Y.XmlElement): boolean {
16
+ return SCHEMA_NODES.has(el.nodeName)
17
+ }
18
+
19
+ /**
20
+ * Ensure the fragment has documentHeader and documentMeta nodes.
21
+ * Creates them if missing. Idempotent — safe to call multiple times.
22
+ */
23
+ export function ensureDocumentStructure(fragment: Y.XmlFragment): void {
24
+ let hasHeader = false
25
+ let hasMeta = false
26
+
27
+ for (let i = 0; i < fragment.length; i++) {
28
+ const child = fragment.get(i)
29
+ if (child instanceof Y.XmlElement) {
30
+ if (child.nodeName === 'documentHeader') hasHeader = true
31
+ if (child.nodeName === 'documentMeta') hasMeta = true
32
+ }
33
+ }
34
+
35
+ // Insert in reverse order so indices stay correct
36
+ if (!hasMeta) {
37
+ const meta = new Y.XmlElement('documentMeta')
38
+ fragment.insert(hasHeader ? 1 : 0, [meta])
39
+ }
40
+ if (!hasHeader) {
41
+ const header = new Y.XmlElement('documentHeader')
42
+ fragment.insert(0, [header])
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Find the index where body content starts (after documentHeader + documentMeta).
48
+ */
49
+ export function bodyStartIndex(fragment: Y.XmlFragment): number {
50
+ let idx = 0
51
+ for (let i = 0; i < fragment.length; i++) {
52
+ const child = fragment.get(i)
53
+ if (child instanceof Y.XmlElement && isSchemaNode(child)) {
54
+ idx = i + 1
55
+ } else {
56
+ break
57
+ }
58
+ }
59
+ return idx
60
+ }
61
+
62
+ /**
63
+ * Walk the XmlFragment body (skipping schema nodes) to find the XmlText node
64
+ * and local offset for a given global character index.
65
+ */
66
+ export function findTextPosition(
67
+ fragment: Y.XmlFragment,
68
+ globalIndex: number
69
+ ): { text: Y.XmlText; offset: number } | null {
70
+ let consumed = 0
71
+ for (let i = 0; i < fragment.length; i++) {
72
+ const child = fragment.get(i)
73
+ if (child instanceof Y.XmlElement) {
74
+ if (isSchemaNode(child)) continue
75
+ const result = findTextInElement(child, globalIndex - consumed)
76
+ if (result) return result
77
+ consumed += elementTextLength(child)
78
+ }
79
+ }
80
+ return null
81
+ }
82
+
83
+ function findTextInElement(
84
+ el: Y.XmlElement,
85
+ remaining: number
86
+ ): { text: Y.XmlText; offset: number } | null {
87
+ for (let i = 0; i < el.length; i++) {
88
+ const child = el.get(i)
89
+ if (child instanceof Y.XmlText) {
90
+ if (remaining <= child.length) {
91
+ return { text: child, offset: remaining }
92
+ }
93
+ remaining -= child.length
94
+ } else if (child instanceof Y.XmlElement) {
95
+ const result = findTextInElement(child, remaining)
96
+ if (result) return result
97
+ remaining -= elementTextLength(child)
98
+ }
99
+ }
100
+ return null
101
+ }
102
+
103
+ /** Total text length of an XmlElement (recursive). */
104
+ export function elementTextLength(el: Y.XmlElement): number {
105
+ let len = 0
106
+ for (let i = 0; i < el.length; i++) {
107
+ const child = el.get(i)
108
+ if (child instanceof Y.XmlText) {
109
+ len += child.length
110
+ } else if (child instanceof Y.XmlElement) {
111
+ len += elementTextLength(child)
112
+ }
113
+ }
114
+ return len
115
+ }
116
+
117
+ /** Total text length of all body paragraphs (skips schema nodes). */
118
+ export function fragmentTextLength(fragment: Y.XmlFragment): number {
119
+ let len = 0
120
+ for (let i = 0; i < fragment.length; i++) {
121
+ const child = fragment.get(i)
122
+ if (child instanceof Y.XmlElement && !isSchemaNode(child)) {
123
+ len += elementTextLength(child)
124
+ }
125
+ }
126
+ return len
127
+ }
128
+
129
+ /**
130
+ * Compute the global character index of a position within a known XmlText node.
131
+ * Re-derives from current document state — safe under concurrent edits.
132
+ * Only counts body content (skips schema nodes).
133
+ */
134
+ export function globalIndexOf(
135
+ fragment: Y.XmlFragment,
136
+ targetText: Y.XmlText,
137
+ localOffset: number
138
+ ): number {
139
+ let index = 0
140
+ for (let i = 0; i < fragment.length; i++) {
141
+ const child = fragment.get(i)
142
+ if (child instanceof Y.XmlElement) {
143
+ if (isSchemaNode(child)) continue
144
+ const result = globalIndexInElement(child, targetText, localOffset, index)
145
+ if (result !== null) return result
146
+ index += elementTextLength(child)
147
+ }
148
+ }
149
+ return index + localOffset // fallback
150
+ }
151
+
152
+ function globalIndexInElement(
153
+ el: Y.XmlElement,
154
+ targetText: Y.XmlText,
155
+ localOffset: number,
156
+ base: number
157
+ ): number | null {
158
+ for (let i = 0; i < el.length; i++) {
159
+ const child = el.get(i)
160
+ if (child instanceof Y.XmlText) {
161
+ if (child === targetText) {
162
+ return base + localOffset
163
+ }
164
+ base += child.length
165
+ } else if (child instanceof Y.XmlElement) {
166
+ const result = globalIndexInElement(child, targetText, localOffset, base)
167
+ if (result !== null) return result
168
+ base += elementTextLength(child)
169
+ }
170
+ }
171
+ return null
172
+ }
173
+
174
+ /**
175
+ * Get or create the last body paragraph's XmlText.
176
+ * Skips documentHeader and documentMeta. Creates a new paragraph if none exist.
177
+ */
178
+ export function getOrCreateLastParagraph(
179
+ fragment: Y.XmlFragment
180
+ ): { text: Y.XmlText; globalOffset: number } {
181
+ // Search backwards for the last body paragraph
182
+ for (let i = fragment.length - 1; i >= 0; i--) {
183
+ const child = fragment.get(i)
184
+ if (child instanceof Y.XmlElement) {
185
+ if (isSchemaNode(child)) continue
186
+ if (child.nodeName === 'paragraph') {
187
+ for (let j = child.length - 1; j >= 0; j--) {
188
+ const grandchild = child.get(j)
189
+ if (grandchild instanceof Y.XmlText) {
190
+ return { text: grandchild, globalOffset: fragmentTextLength(fragment) }
191
+ }
192
+ }
193
+ // Paragraph exists but has no XmlText — add one
194
+ const xt = new Y.XmlText()
195
+ child.insert(0, [xt])
196
+ return { text: xt, globalOffset: fragmentTextLength(fragment) }
197
+ }
198
+ }
199
+ }
200
+
201
+ // No body paragraph found — create one after schema nodes
202
+ const para = new Y.XmlElement('paragraph')
203
+ const xt = new Y.XmlText()
204
+ para.insert(0, [xt])
205
+ fragment.push([para])
206
+ return { text: xt, globalOffset: 0 }
207
+ }
208
+
209
+ /**
210
+ * Insert a new paragraph after the one containing the given global character index.
211
+ * Skips schema nodes when computing positions.
212
+ */
213
+ export function insertParagraphAfter(
214
+ fragment: Y.XmlFragment,
215
+ afterGlobalIndex: number
216
+ ): Y.XmlText {
217
+ let offset = 0
218
+ let insertAt = fragment.length
219
+
220
+ for (let i = 0; i < fragment.length; i++) {
221
+ const child = fragment.get(i)
222
+ if (child instanceof Y.XmlElement) {
223
+ if (isSchemaNode(child)) continue
224
+ const elLen = elementTextLength(child)
225
+ if (offset + elLen >= afterGlobalIndex) {
226
+ insertAt = i + 1
227
+ break
228
+ }
229
+ offset += elLen
230
+ }
231
+ }
232
+
233
+ const para = new Y.XmlElement('paragraph')
234
+ const xt = new Y.XmlText()
235
+ para.insert(0, [xt])
236
+ fragment.insert(insertAt, [para])
237
+ return xt
238
+ }