@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,197 @@
1
+ /**
2
+ * Action dispatcher — routes Action.type to the correct executor.
3
+ */
4
+ import type { ActorConnection } from '../actor-connection.ts'
5
+ import type { Action, TimelineEntry, ServerConfig } from '../types.ts'
6
+ import { executeConnect, executeDisconnect } from './connect.ts'
7
+ import { executeNavigate } from './navigate.ts'
8
+ import { executeType, executeTypeDelete } from './type.ts'
9
+ import { executeMoveCursor, executeSelect } from './cursor.ts'
10
+ import { executeWriteContent, executeDeleteContent } from './content.ts'
11
+ import {
12
+ executeSetStatus,
13
+ executeSetAwareness,
14
+ executeClearAwareness,
15
+ executePointerMove,
16
+ executeScrollTo,
17
+ executeKanbanHover,
18
+ executeKanbanDrag,
19
+ } from './awareness.ts'
20
+ import { executeCreateDocument, executeMoveDocument, executeSetMeta, executeRenameDocument } from './document.ts'
21
+ import { executeSendChat } from './chat.ts'
22
+ import { executeWait, executeParallel, executeSequence, executeRepeat } from './flow.ts'
23
+
24
+ export type ActionExecutor = (entry: TimelineEntry) => Promise<void>
25
+
26
+ export interface ActionContext {
27
+ actors: Map<string, ActorConnection>
28
+ serverConfig: ServerConfig
29
+ vars: Map<string, string>
30
+ }
31
+
32
+ /**
33
+ * Resolve variable references in string values.
34
+ * Replaces ${varName} with the value from vars map.
35
+ */
36
+ function resolveVars(value: string, vars: Map<string, string>): string {
37
+ return value.replace(/\$\{(\w+)\}/g, (_, key) => vars.get(key) ?? `\${${key}}`)
38
+ }
39
+
40
+ function resolveActionVars(action: Action, vars: Map<string, string>): Action {
41
+ if (vars.size === 0) return action
42
+
43
+ // Clone to avoid mutating the original scene definition
44
+ const a = { ...action } as any
45
+
46
+ // Resolve ID fields
47
+ if (a.docId && typeof a.docId === 'string') a.docId = resolveVars(a.docId, vars)
48
+ if (a.parentId && typeof a.parentId === 'string') a.parentId = resolveVars(a.parentId, vars)
49
+ if (a.newParentId && typeof a.newParentId === 'string') a.newParentId = resolveVars(a.newParentId, vars)
50
+ if (a.cardId && typeof a.cardId === 'string') a.cardId = resolveVars(a.cardId, vars)
51
+ if (a.toColumnId && typeof a.toColumnId === 'string') a.toColumnId = resolveVars(a.toColumnId, vars)
52
+
53
+ // Resolve content fields
54
+ if (a.text && typeof a.text === 'string') a.text = resolveVars(a.text, vars)
55
+ if (a.label && typeof a.label === 'string') a.label = resolveVars(a.label, vars)
56
+ if (a.markdown && typeof a.markdown === 'string') a.markdown = resolveVars(a.markdown, vars)
57
+ if (a.channel && typeof a.channel === 'string') a.channel = resolveVars(a.channel, vars)
58
+ if (a.message && typeof a.message === 'string') a.message = resolveVars(a.message, vars)
59
+
60
+ return a
61
+ }
62
+
63
+ export function createExecutor(ctx: ActionContext): ActionExecutor {
64
+ const execute: ActionExecutor = async (entry: TimelineEntry) => {
65
+ const action = resolveActionVars(entry.action, ctx.vars)
66
+ const actor = entry.actor ? ctx.actors.get(entry.actor) : undefined
67
+
68
+ switch (action.type) {
69
+ case 'connect': {
70
+ if (!actor) throw new Error('connect requires an actor')
71
+ await executeConnect(actor, ctx.serverConfig)
72
+ break
73
+ }
74
+ case 'disconnect': {
75
+ if (!actor) throw new Error('disconnect requires an actor')
76
+ await executeDisconnect(actor)
77
+ break
78
+ }
79
+ case 'navigate': {
80
+ if (!actor) throw new Error('navigate requires an actor')
81
+ await executeNavigate(actor, action)
82
+ break
83
+ }
84
+ case 'type': {
85
+ if (!actor) throw new Error('type requires an actor')
86
+ await executeType(actor, action)
87
+ break
88
+ }
89
+ case 'typeDelete': {
90
+ if (!actor) throw new Error('typeDelete requires an actor')
91
+ await executeTypeDelete(actor, action)
92
+ break
93
+ }
94
+ case 'select': {
95
+ if (!actor) throw new Error('select requires an actor')
96
+ await executeSelect(actor, action)
97
+ break
98
+ }
99
+ case 'moveCursor': {
100
+ if (!actor) throw new Error('moveCursor requires an actor')
101
+ await executeMoveCursor(actor, action)
102
+ break
103
+ }
104
+ case 'setStatus': {
105
+ if (!actor) throw new Error('setStatus requires an actor')
106
+ await executeSetStatus(actor, action)
107
+ break
108
+ }
109
+ case 'setAwareness': {
110
+ if (!actor) throw new Error('setAwareness requires an actor')
111
+ await executeSetAwareness(actor, action)
112
+ break
113
+ }
114
+ case 'clearAwareness': {
115
+ if (!actor) throw new Error('clearAwareness requires an actor')
116
+ await executeClearAwareness(actor, action)
117
+ break
118
+ }
119
+ case 'createDocument': {
120
+ if (!actor) throw new Error('createDocument requires an actor')
121
+ await executeCreateDocument(actor, action, ctx.vars)
122
+ break
123
+ }
124
+ case 'moveDocument': {
125
+ if (!actor) throw new Error('moveDocument requires an actor')
126
+ await executeMoveDocument(actor, action)
127
+ break
128
+ }
129
+ case 'renameDocument': {
130
+ if (!actor) throw new Error('renameDocument requires an actor')
131
+ await executeRenameDocument(actor, action)
132
+ break
133
+ }
134
+ case 'writeContent': {
135
+ if (!actor) throw new Error('writeContent requires an actor')
136
+ await executeWriteContent(actor, action)
137
+ break
138
+ }
139
+ case 'deleteContent': {
140
+ if (!actor) throw new Error('deleteContent requires an actor')
141
+ await executeDeleteContent(actor, action)
142
+ break
143
+ }
144
+ case 'setMeta': {
145
+ if (!actor) throw new Error('setMeta requires an actor')
146
+ await executeSetMeta(actor, action)
147
+ break
148
+ }
149
+ case 'pointerMove': {
150
+ if (!actor) throw new Error('pointerMove requires an actor')
151
+ await executePointerMove(actor, action)
152
+ break
153
+ }
154
+ case 'scrollTo': {
155
+ if (!actor) throw new Error('scrollTo requires an actor')
156
+ await executeScrollTo(actor, action)
157
+ break
158
+ }
159
+ case 'kanbanHover': {
160
+ if (!actor) throw new Error('kanbanHover requires an actor')
161
+ await executeKanbanHover(actor, action)
162
+ break
163
+ }
164
+ case 'kanbanDrag': {
165
+ if (!actor) throw new Error('kanbanDrag requires an actor')
166
+ await executeKanbanDrag(actor, action)
167
+ break
168
+ }
169
+ case 'sendChat': {
170
+ if (!actor) throw new Error('sendChat requires an actor')
171
+ await executeSendChat(actor, action)
172
+ break
173
+ }
174
+ case 'wait': {
175
+ await executeWait(action)
176
+ break
177
+ }
178
+ case 'parallel': {
179
+ await executeParallel(action, execute)
180
+ break
181
+ }
182
+ case 'sequence': {
183
+ await executeSequence(action, execute)
184
+ break
185
+ }
186
+ case 'repeat': {
187
+ await executeRepeat(action, execute)
188
+ break
189
+ }
190
+ default: {
191
+ throw new Error(`Unknown action type: ${(action as any).type}`)
192
+ }
193
+ }
194
+ }
195
+
196
+ return execute
197
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Navigate action — sets which document an actor is viewing.
3
+ */
4
+ import type { ActorConnection } from '../actor-connection.ts'
5
+ import type { NavigateAction } from '../types.ts'
6
+
7
+ export async function executeNavigate(
8
+ actor: ActorConnection,
9
+ action: NavigateAction
10
+ ): Promise<void> {
11
+ // Set docId in root awareness (this is what the dashboard watches for presence)
12
+ actor.setRootAwareness('docId', action.docId)
13
+ // Pre-load the child provider so subsequent actions on this doc are fast
14
+ await actor.getChildProvider(action.docId)
15
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Typewriter action — character-by-character text insertion with live cursor.
3
+ *
4
+ * TipTap Y.js structure: XmlFragment('default') contains:
5
+ * [documentHeader, documentMeta, ...body blocks]
6
+ * Each paragraph is an XmlElement('paragraph') containing XmlText nodes.
7
+ *
8
+ * All character indices are relative to body content only (schema nodes skipped).
9
+ *
10
+ * TypeDelete action — backspace simulation, deletes characters one at a time.
11
+ */
12
+ import * as Y from 'yjs'
13
+ import type { ActorConnection } from '../actor-connection.ts'
14
+ import type { TypeAction, TypeDeleteAction } from '../types.ts'
15
+ import { sleep } from '../utils.ts'
16
+ import {
17
+ ensureDocumentStructure,
18
+ findTextPosition,
19
+ fragmentTextLength,
20
+ getOrCreateLastParagraph,
21
+ insertParagraphAfter,
22
+ globalIndexOf,
23
+ } from '../yjs-utils.ts'
24
+
25
+ export async function executeType(
26
+ actor: ActorConnection,
27
+ action: TypeAction
28
+ ): Promise<void> {
29
+ const speed = action.speed ?? 80
30
+ const variance = action.variance ?? 30
31
+ const provider = await actor.getChildProvider(action.docId)
32
+ const doc = provider.document
33
+ const fragment = doc.getXmlFragment('default')
34
+
35
+ // Ensure TipTap schema nodes exist
36
+ doc.transact(() => ensureDocumentStructure(fragment))
37
+
38
+ // Determine starting position
39
+ const startIndex = action.position ?? fragmentTextLength(fragment)
40
+
41
+ // Find the XmlText node at the starting position
42
+ let currentText: Y.XmlText
43
+ let currentLocalOffset: number
44
+
45
+ const pos = findTextPosition(fragment, startIndex)
46
+ if (pos) {
47
+ currentText = pos.text
48
+ currentLocalOffset = pos.offset
49
+ } else {
50
+ const last = getOrCreateLastParagraph(fragment)
51
+ currentText = last.text
52
+ currentLocalOffset = currentText.length
53
+ }
54
+
55
+ for (const char of action.text) {
56
+ const delay = Math.max(10, speed + (Math.random() * 2 - 1) * variance)
57
+ await sleep(delay)
58
+
59
+ if (char === '\n') {
60
+ // Re-derive global index from current node position (concurrent-safe)
61
+ const globalIdx = globalIndexOf(fragment, currentText, currentLocalOffset)
62
+ doc.transact(() => {
63
+ currentText = insertParagraphAfter(fragment, globalIdx)
64
+ currentLocalOffset = 0
65
+ })
66
+ } else {
67
+ doc.transact(() => {
68
+ currentText.insert(currentLocalOffset, char)
69
+ })
70
+ currentLocalOffset++
71
+ }
72
+
73
+ // Update cursor — re-derive global position for accuracy
74
+ const cursorGlobal = globalIndexOf(fragment, currentText, currentLocalOffset)
75
+ actor.setCursor(action.docId, cursorGlobal)
76
+ }
77
+ }
78
+
79
+ export async function executeTypeDelete(
80
+ actor: ActorConnection,
81
+ action: TypeDeleteAction
82
+ ): Promise<void> {
83
+ const speed = action.speed ?? 60
84
+ const variance = action.variance ?? 20
85
+ const provider = await actor.getChildProvider(action.docId)
86
+ const doc = provider.document
87
+ const fragment = doc.getXmlFragment('default')
88
+
89
+ // Find starting position
90
+ const startIndex = action.position ?? fragmentTextLength(fragment)
91
+ let remaining = action.count
92
+
93
+ while (remaining > 0) {
94
+ const delay = Math.max(10, speed + (Math.random() * 2 - 1) * variance)
95
+ await sleep(delay)
96
+
97
+ // Re-find position each iteration (concurrent-safe)
98
+ const deleteAt = Math.max(0, startIndex - (action.count - remaining) - 1)
99
+ const pos = findTextPosition(fragment, deleteAt)
100
+ if (!pos || deleteAt < 0) break
101
+
102
+ doc.transact(() => {
103
+ if (pos.offset > 0) {
104
+ pos.text.delete(pos.offset - 1, 1)
105
+ }
106
+ })
107
+
108
+ remaining--
109
+
110
+ // Update cursor
111
+ actor.setCursor(action.docId, Math.max(0, deleteAt))
112
+ }
113
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * ActorConnection — manages a single actor's connection to the Abracadabra server.
3
+ * One instance per actor. Handles auth, providers, child caching, awareness, and cursors.
4
+ */
5
+ import * as Y from 'yjs'
6
+ import { AbracadabraProvider, AbracadabraClient } from '@abraca/dabra'
7
+ import type { ActorDef, ServerConfig } from './types.ts'
8
+ import { loadOrCreateKeypair, deterministicKeypair, signChallenge } from './crypto.ts'
9
+ import { waitForSync, sleep, log } from './utils.ts'
10
+ import { findTextPosition, fragmentTextLength } from './yjs-utils.ts'
11
+
12
+ interface CachedChild {
13
+ provider: AbracadabraProvider
14
+ lastAccessed: number
15
+ }
16
+
17
+ export class ActorConnection {
18
+ readonly actor: ActorDef
19
+ readonly client: AbracadabraClient
20
+ private _rootDocId: string | null = null
21
+ private _rootDoc: Y.Doc | null = null
22
+ private _rootProvider: AbracadabraProvider | null = null
23
+ private _publicKey: string | null = null
24
+ private _childCache = new Map<string, CachedChild>()
25
+
26
+ constructor(actor: ActorDef, serverConfig: ServerConfig) {
27
+ this.actor = actor
28
+ // AbracadabraClient expects an HTTP(S) URL — normalize ws(s):// if provided
29
+ const httpUrl = serverConfig.url
30
+ .replace(/^wss:\/\//, 'https://')
31
+ .replace(/^ws:\/\//, 'http://')
32
+ this.client = new AbracadabraClient({ url: httpUrl })
33
+ }
34
+
35
+ get rootProvider(): AbracadabraProvider | null {
36
+ return this._rootProvider
37
+ }
38
+
39
+ get rootDocId(): string | null {
40
+ return this._rootDocId
41
+ }
42
+
43
+ /** Connect, authenticate, discover root doc, sync, and set awareness. */
44
+ async connect(serverConfig: ServerConfig): Promise<void> {
45
+ // Step 1: Get keypair (from file or deterministic from name)
46
+ const keypair = this.actor.keyFile
47
+ ? await loadOrCreateKeypair(this.actor.keyFile)
48
+ : deterministicKeypair(this.actor.name)
49
+ this._publicKey = keypair.publicKeyB64
50
+ const signFn = (challenge: string) =>
51
+ Promise.resolve(signChallenge(challenge, keypair.privateKey))
52
+
53
+ // Step 2: Auth (auto-register on first run)
54
+ try {
55
+ await this.client.loginWithKey(keypair.publicKeyB64, signFn)
56
+ } catch (err: any) {
57
+ const status = err?.status ?? err?.response?.status
58
+ if (status === 404 || status === 422) {
59
+ log(`${this.actor.name}: registering new account...`)
60
+ await this.client.registerWithKey({
61
+ publicKey: keypair.publicKeyB64,
62
+ username: this.actor.name.replace(/\s+/g, '-').toLowerCase(),
63
+ displayName: this.actor.name,
64
+ deviceName: 'Orchestrator Actor',
65
+ inviteCode: serverConfig.inviteCode,
66
+ })
67
+ await this.client.loginWithKey(keypair.publicKeyB64, signFn)
68
+ } else {
69
+ throw err
70
+ }
71
+ }
72
+ log(`${this.actor.name}: authenticated (${keypair.publicKeyB64.slice(0, 12)}...)`)
73
+
74
+ // Step 3: Pick an entry-point doc — first Space under the server root,
75
+ // falling back to the first top-level doc of any kind.
76
+ const roots = await this.client.listChildren()
77
+ const firstSpace = roots.find((d) => d.kind === 'space')
78
+ const rootDocId = (firstSpace ?? roots[0])?.id ?? null
79
+
80
+ if (!rootDocId) {
81
+ throw new Error(`${this.actor.name}: no root document found`)
82
+ }
83
+ this._rootDocId = rootDocId
84
+
85
+ // Step 4: Connect provider
86
+ const doc = new Y.Doc({ guid: rootDocId })
87
+ const provider = new AbracadabraProvider({
88
+ name: rootDocId,
89
+ document: doc,
90
+ client: this.client,
91
+ disableOfflineStore: true,
92
+ subdocLoading: 'lazy',
93
+ })
94
+ await waitForSync(provider)
95
+
96
+ this._rootDoc = doc
97
+ this._rootProvider = provider
98
+
99
+ // Step 5: Set awareness — actors appear as humans (isAgent: false)
100
+ provider.awareness.setLocalStateField('user', {
101
+ name: this.actor.name,
102
+ color: this.actor.color,
103
+ publicKey: this._publicKey,
104
+ isAgent: false,
105
+ })
106
+ provider.awareness.setLocalStateField('status', null)
107
+
108
+ log(`${this.actor.name}: connected to space ${rootDocId}`)
109
+ }
110
+
111
+ /** Get or create a child provider for a document. Caches and sets awareness. */
112
+ async getChildProvider(docId: string): Promise<AbracadabraProvider> {
113
+ const cached = this._childCache.get(docId)
114
+ if (cached) {
115
+ cached.lastAccessed = Date.now()
116
+ return cached.provider
117
+ }
118
+
119
+ if (!this._rootProvider) {
120
+ throw new Error(`${this.actor.name}: not connected`)
121
+ }
122
+
123
+ const childProvider = await this._rootProvider.loadChild(docId)
124
+ await waitForSync(childProvider)
125
+
126
+ childProvider.awareness.setLocalStateField('user', {
127
+ name: this.actor.name,
128
+ color: this.actor.color,
129
+ publicKey: this._publicKey,
130
+ isAgent: false,
131
+ })
132
+
133
+ this._childCache.set(docId, {
134
+ provider: childProvider,
135
+ lastAccessed: Date.now(),
136
+ })
137
+
138
+ return childProvider
139
+ }
140
+
141
+ /**
142
+ * Set cursor position in a document (collapsed — anchor = head).
143
+ *
144
+ * TipTap's yCursorPlugin reads awareness field 'cursor' as
145
+ * { anchor: RelativePosJSON, head: RelativePosJSON }.
146
+ * Positions must point into XmlText nodes (not the fragment) with assoc=-1.
147
+ */
148
+ setCursor(docId: string, index: number): void {
149
+ const cached = this._childCache.get(docId)
150
+ if (!cached) return
151
+
152
+ const fragment = cached.provider.document.getXmlFragment('default')
153
+ const relPos = this._charIndexToRelativePosition(fragment, index)
154
+ if (!relPos) return
155
+
156
+ const json = Y.relativePositionToJSON(relPos)
157
+ cached.provider.awareness.setLocalStateField('cursor', {
158
+ anchor: json,
159
+ head: json,
160
+ })
161
+ }
162
+
163
+ /** Set text selection range in a document (anchor != head). */
164
+ setSelection(docId: string, anchor: number, head: number): void {
165
+ const cached = this._childCache.get(docId)
166
+ if (!cached) return
167
+
168
+ const fragment = cached.provider.document.getXmlFragment('default')
169
+ const anchorPos = this._charIndexToRelativePosition(fragment, anchor)
170
+ const headPos = this._charIndexToRelativePosition(fragment, head)
171
+ if (!anchorPos || !headPos) return
172
+
173
+ cached.provider.awareness.setLocalStateField('cursor', {
174
+ anchor: Y.relativePositionToJSON(anchorPos),
175
+ head: Y.relativePositionToJSON(headPos),
176
+ })
177
+ }
178
+
179
+ /**
180
+ * Convert a character index (body text only, skipping schema nodes)
181
+ * to a Y.js RelativePosition pointing into the correct XmlText node.
182
+ * Uses assoc=-1 (associate-left) to match TipTap's cursor behavior.
183
+ */
184
+ private _charIndexToRelativePosition(
185
+ fragment: Y.XmlFragment,
186
+ charIndex: number
187
+ ): Y.RelativePosition | null {
188
+ const totalLen = fragmentTextLength(fragment)
189
+ const clamped = Math.max(0, Math.min(charIndex, totalLen))
190
+
191
+ if (clamped === 0) {
192
+ // Position at start of fragment — use assoc=-1 on the fragment itself
193
+ return Y.createRelativePositionFromTypeIndex(fragment, 0, -1)
194
+ }
195
+
196
+ const pos = findTextPosition(fragment, clamped)
197
+ if (pos) {
198
+ return Y.createRelativePositionFromTypeIndex(pos.text, pos.offset, -1)
199
+ }
200
+
201
+ // Fallback: end of fragment
202
+ return Y.createRelativePositionFromTypeIndex(fragment, fragment.length, -1)
203
+ }
204
+
205
+ /** Set a field on root awareness. */
206
+ setRootAwareness(field: string, value: unknown): void {
207
+ this._rootProvider?.awareness.setLocalStateField(field, value)
208
+ }
209
+
210
+ /** Set a field on a child document's awareness. */
211
+ setChildAwareness(docId: string, field: string, value: unknown): void {
212
+ const cached = this._childCache.get(docId)
213
+ cached?.provider.awareness.setLocalStateField(field, value)
214
+ }
215
+
216
+ /** Disconnect and clean up all providers. */
217
+ async disconnect(): Promise<void> {
218
+ // Clear awareness on children
219
+ for (const [, cached] of this._childCache) {
220
+ cached.provider.awareness.setLocalStateField('cursor', null)
221
+ }
222
+
223
+ // Clear root awareness
224
+ if (this._rootProvider) {
225
+ this._rootProvider.awareness.setLocalStateField('status', null)
226
+ this._rootProvider.awareness.setLocalStateField('docId', null)
227
+ }
228
+
229
+ // Wait for awareness updates to propagate before destroying
230
+ await sleep(150)
231
+
232
+ for (const [, cached] of this._childCache) {
233
+ cached.provider.destroy()
234
+ }
235
+ this._childCache.clear()
236
+
237
+ if (this._rootProvider) {
238
+ this._rootProvider.destroy()
239
+ this._rootProvider = null
240
+ }
241
+ this._rootDoc = null
242
+ this._rootDocId = null
243
+
244
+ log(`${this.actor.name}: disconnected`)
245
+ }
246
+ }