@knowlearning/agents 0.9.23 → 0.9.25
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/agents/browser/root.js +1 -1
- package/agents/deno.js +1 -1
- package/agents/generic/index.js +211 -0
- package/agents/generic/message-queue.js +227 -0
- package/agents/generic/state.js +59 -0
- package/agents/generic/watch.js +82 -0
- package/agents/node.js +1 -1
- package/browser.js +1 -1
- package/node.js +1 -1
- package/package.json +1 -1
- package/vue/3/components/content.vue +5 -12
- package/agents/generic.js +0 -536
package/agents/browser/root.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { v1 as uuid } from 'uuid'
|
|
2
2
|
import { applyPatch } from 'fast-json-patch'
|
|
3
3
|
import { getToken, login, logout } from './auth.js'
|
|
4
|
-
import GenericAgent from '../generic.js'
|
|
4
|
+
import GenericAgent from '../generic/index.js'
|
|
5
5
|
|
|
6
6
|
const DEVELOPMENT_HOST = 'localhost:32001'
|
|
7
7
|
const REMOTE_HOST = 'api.knowlearning.systems'
|
package/agents/deno.js
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { validate as isUUID } from 'uuid'
|
|
2
|
+
import MutableProxy from '../../persistence/json.js'
|
|
3
|
+
import messageQueue from './message-queue.js'
|
|
4
|
+
import stateImplementation from './state.js'
|
|
5
|
+
import watchImplementation from './watch.js'
|
|
6
|
+
import downloadImplementation from '../download.js'
|
|
7
|
+
|
|
8
|
+
// TODO: consider using something better than name as mechanism
|
|
9
|
+
// for resoling default scope in context
|
|
10
|
+
const DEFAULT_SCOPE_NAME = '[]'
|
|
11
|
+
const UPLOAD_TYPE = 'application/json;type=upload'
|
|
12
|
+
const POSTGRES_QUERY_TYPE = 'application/json;type=postgres-query'
|
|
13
|
+
const TAG_TYPE = 'application/json;type=tag'
|
|
14
|
+
const DOMAIN_CLAIM_TYPE = 'application/json;type=domain-claim'
|
|
15
|
+
|
|
16
|
+
export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fetch, applyPatch, login, logout, reboot }) {
|
|
17
|
+
const states = {}
|
|
18
|
+
const watchers = {}
|
|
19
|
+
const keyToSubscriptionId = {}
|
|
20
|
+
const lastInteractionResponse = {}
|
|
21
|
+
const tagTypeToTargetCache = {}
|
|
22
|
+
let resolveEnvironment
|
|
23
|
+
let mode = 'normal'
|
|
24
|
+
const environmentPromise = new Promise(r => resolveEnvironment = r)
|
|
25
|
+
|
|
26
|
+
log('INITIALIZING AGENT CONNECTION')
|
|
27
|
+
const [
|
|
28
|
+
queueMessage,
|
|
29
|
+
lastMessageResponse,
|
|
30
|
+
disconnect,
|
|
31
|
+
reconnect,
|
|
32
|
+
synced
|
|
33
|
+
] = messageQueue(resolveEnvironment, { token, protocol, host, WebSocket, watchers, states, applyPatch, log, login, interact })
|
|
34
|
+
|
|
35
|
+
const internalReferences = {
|
|
36
|
+
keyToSubscriptionId,
|
|
37
|
+
watchers,
|
|
38
|
+
states,
|
|
39
|
+
state,
|
|
40
|
+
create,
|
|
41
|
+
environment,
|
|
42
|
+
lastInteractionResponse,
|
|
43
|
+
lastMessageResponse,
|
|
44
|
+
tagIfNotYetTaggedInSession,
|
|
45
|
+
interact,
|
|
46
|
+
fetch,
|
|
47
|
+
metadata
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const [ watch, removeWatcher ] = watchImplementation(internalReferences)
|
|
51
|
+
|
|
52
|
+
async function environment() { return { ...(await environmentPromise), context: [] } }
|
|
53
|
+
|
|
54
|
+
function state(scope, user) { return stateImplementation(scope, user, internalReferences) }
|
|
55
|
+
|
|
56
|
+
function download(id) { return downloadImplementation(id, internalReferences) }
|
|
57
|
+
|
|
58
|
+
function debug() { mode = 'debug' }
|
|
59
|
+
|
|
60
|
+
function log() { if (mode === 'debug') console.log(...arguments) }
|
|
61
|
+
|
|
62
|
+
function create({ id=uuid(), active_type, active, name }) {
|
|
63
|
+
const patch = [
|
|
64
|
+
{ op: 'add', path: ['active_type'], value: active_type },
|
|
65
|
+
{ op: 'add', path: ['active'], value: active }
|
|
66
|
+
]
|
|
67
|
+
interact(id, patch, false)
|
|
68
|
+
return id
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function tagIfNotYetTaggedInSession(tag_type, target) {
|
|
72
|
+
const targetCache = tagTypeToTargetCache[tag_type]
|
|
73
|
+
if (targetCache && targetCache[target]) return
|
|
74
|
+
|
|
75
|
+
if (!targetCache) tagTypeToTargetCache[tag_type] = {}
|
|
76
|
+
if (tagTypeToTargetCache[tag_type][target]) return
|
|
77
|
+
|
|
78
|
+
tagTypeToTargetCache[tag_type][target] = true
|
|
79
|
+
|
|
80
|
+
// always use absolute referene when tagging
|
|
81
|
+
if (!isUUID(target)) target = (await metadata(target)).id
|
|
82
|
+
|
|
83
|
+
await tag(tag_type, target)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// TODO: if no data, set up streaming upload
|
|
87
|
+
async function upload(name, type, data, id=uuid()) {
|
|
88
|
+
// TODO: include data size info...
|
|
89
|
+
create({
|
|
90
|
+
active_type: UPLOAD_TYPE,
|
|
91
|
+
active: { id, type }
|
|
92
|
+
})
|
|
93
|
+
const { url } = await lastMessageResponse()
|
|
94
|
+
|
|
95
|
+
if (data === undefined) return url
|
|
96
|
+
else {
|
|
97
|
+
const headers = { 'Content-Type': type }
|
|
98
|
+
const response = await fetch(url, {method: 'PUT', headers, body: data})
|
|
99
|
+
const { ok, statusText } = response
|
|
100
|
+
|
|
101
|
+
if (ok) return id
|
|
102
|
+
else throw new Error(statusText)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// TODO: addTag option should probably not be exposed
|
|
107
|
+
async function interact(scope=DEFAULT_SCOPE_NAME, patch, addTag=true) {
|
|
108
|
+
if (addTag) tagIfNotYetTaggedInSession('mutated', scope)
|
|
109
|
+
// TODO: ensure user is owner of scope
|
|
110
|
+
const response = queueMessage({scope, patch})
|
|
111
|
+
|
|
112
|
+
// if we are watching this scope, we want to keep track of last interaction we fired
|
|
113
|
+
const qualifiedScope = isUUID(scope) ? scope : `/${scope}`
|
|
114
|
+
if (states[qualifiedScope] !== undefined) {
|
|
115
|
+
let resolve
|
|
116
|
+
lastInteractionResponse[qualifiedScope] = new Promise(r => resolve = r)
|
|
117
|
+
|
|
118
|
+
const resolveAndUnwatch = async (update) => {
|
|
119
|
+
const { ii } = await response
|
|
120
|
+
if (update.ii === ii) {
|
|
121
|
+
resolve(ii)
|
|
122
|
+
removeWatcher(qualifiedScope, resolveAndUnwatch)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
watchers[qualifiedScope].push(resolveAndUnwatch)
|
|
127
|
+
|
|
128
|
+
return response
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const { ii } = await response
|
|
132
|
+
return { ii }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function claim(domain) {
|
|
137
|
+
const id = uuid()
|
|
138
|
+
create({
|
|
139
|
+
id,
|
|
140
|
+
active_type: DOMAIN_CLAIM_TYPE,
|
|
141
|
+
active: { domain }
|
|
142
|
+
})
|
|
143
|
+
return lastMessageResponse()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function reset(scope=DEFAULT_SCOPE_NAME) {
|
|
147
|
+
return interact(scope, [{ op: 'remove', path:['active'] }])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isValidMetadataMutation({ path, op, value }) {
|
|
151
|
+
return (
|
|
152
|
+
['active_type', 'name'].includes(path[0])
|
|
153
|
+
&& path.length === 1
|
|
154
|
+
&& typeof value === 'string' || op === 'remove'
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function metadata(id=DEFAULT_SCOPE_NAME, user) {
|
|
159
|
+
const md = structuredClone(await state(id, user).metadata)
|
|
160
|
+
delete md.active
|
|
161
|
+
return new MutableProxy(md, patch => {
|
|
162
|
+
const activePatch = structuredClone(patch)
|
|
163
|
+
if (!activePatch.every(isValidMetadataMutation)) {
|
|
164
|
+
throw new Error("You may only modify the type or name for a scope's metadata")
|
|
165
|
+
}
|
|
166
|
+
// TODO: if user is not active user, reject the metadata update with error
|
|
167
|
+
interact(id, activePatch)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function query(query, params, domain) {
|
|
172
|
+
const id = uuid()
|
|
173
|
+
create({
|
|
174
|
+
id,
|
|
175
|
+
active_type: POSTGRES_QUERY_TYPE,
|
|
176
|
+
active: { query, params, domain, requested: Date.now() },
|
|
177
|
+
})
|
|
178
|
+
const { rows } = await lastMessageResponse()
|
|
179
|
+
interact(id, [{ op: 'add', path: ['active', 'responded'], value: Date.now() }])
|
|
180
|
+
return rows
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function tag(tag_type, target, context=[]) {
|
|
184
|
+
return create({
|
|
185
|
+
active_type: TAG_TYPE,
|
|
186
|
+
active: { tag_type, target, context }
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
uuid,
|
|
192
|
+
environment,
|
|
193
|
+
login,
|
|
194
|
+
logout,
|
|
195
|
+
create,
|
|
196
|
+
state,
|
|
197
|
+
watch,
|
|
198
|
+
upload,
|
|
199
|
+
download,
|
|
200
|
+
interact,
|
|
201
|
+
claim,
|
|
202
|
+
reset,
|
|
203
|
+
metadata,
|
|
204
|
+
query,
|
|
205
|
+
synced,
|
|
206
|
+
disconnect,
|
|
207
|
+
reconnect,
|
|
208
|
+
tag,
|
|
209
|
+
debug
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { validate as isUUID } from 'uuid'
|
|
2
|
+
|
|
3
|
+
const HEARTBEAT_TIMEOUT = 10000
|
|
4
|
+
|
|
5
|
+
// transform our custom path implementation to the standard JSONPatch path
|
|
6
|
+
function standardJSONPatch(patch) {
|
|
7
|
+
return patch.map(p => {
|
|
8
|
+
return {...p, path: '/' + p.path.map(sanitizeJSONPatchPathSegment).join('/')}
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sanitizeJSONPatchPathSegment(s) {
|
|
13
|
+
if (typeof s === "string") return s.replaceAll('~', '~0').replaceAll('/', '~1')
|
|
14
|
+
else return s
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function messageQueue(setEnvironment, { token, protocol, host, WebSocket, watchers, states, applyPatch, log, login, interact }) {
|
|
18
|
+
let ws
|
|
19
|
+
let user
|
|
20
|
+
let authed = false
|
|
21
|
+
let isSynced = false
|
|
22
|
+
let session
|
|
23
|
+
let server
|
|
24
|
+
let si = -1
|
|
25
|
+
let failedConnections = 0
|
|
26
|
+
const messageQueue = []
|
|
27
|
+
let lastSentSI = -1
|
|
28
|
+
let lastHeartbeat
|
|
29
|
+
let lastSynchronousScopePatched = null
|
|
30
|
+
let lastSynchronousScopePatchPromise = null
|
|
31
|
+
let restarting = false
|
|
32
|
+
let disconnected = false
|
|
33
|
+
const syncedPromiseResolutions = []
|
|
34
|
+
const responses = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
const sessionMetrics = {
|
|
38
|
+
loaded: Date.now(),
|
|
39
|
+
connected: null,
|
|
40
|
+
authenticated: null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function queueMessage({ scope, patch }) {
|
|
44
|
+
isSynced = false
|
|
45
|
+
if (lastSynchronousScopePatched === scope) {
|
|
46
|
+
const i = messageQueue.length - 1
|
|
47
|
+
messageQueue[i].patch = [...messageQueue[i].patch, ...patch]
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
si += 1
|
|
51
|
+
lastSynchronousScopePatchPromise = new Promise((resolve, reject) => responses[si] = [[resolve, reject]])
|
|
52
|
+
messageQueue.push({ scope, patch, si, ts: Date.now()})
|
|
53
|
+
lastSynchronousScopePatched = scope
|
|
54
|
+
flushMessageQueue()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return lastSynchronousScopePatchPromise
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// TODO: clear acknowledged messages
|
|
61
|
+
async function flushMessageQueue() {
|
|
62
|
+
// this makes flushing async, giving time for queue message to combine synchronous updates
|
|
63
|
+
await new Promise(r=>r())
|
|
64
|
+
lastSynchronousScopePatched = null
|
|
65
|
+
|
|
66
|
+
while (authed && ws.readyState === WebSocket.OPEN && lastSentSI+1 < messageQueue.length) {
|
|
67
|
+
lastSynchronousScopePatched = null
|
|
68
|
+
lastSentSI += 1
|
|
69
|
+
ws.send(JSON.stringify(messageQueue[lastSentSI]))
|
|
70
|
+
|
|
71
|
+
// async so we don't try and push more to a closed connection
|
|
72
|
+
await new Promise(r=>r())
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveSyncPromises() {
|
|
77
|
+
while (syncedPromiseResolutions.length) syncedPromiseResolutions.shift()()
|
|
78
|
+
isSynced = true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkHeartbeat() {
|
|
82
|
+
clearTimeout(lastHeartbeat)
|
|
83
|
+
lastHeartbeat = setTimeout(
|
|
84
|
+
() => {
|
|
85
|
+
log('CLOSING DUE TO HEARTBEAT TIMEOUT')
|
|
86
|
+
restartConnection()
|
|
87
|
+
},
|
|
88
|
+
HEARTBEAT_TIMEOUT
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function restartConnection() {
|
|
93
|
+
if (restarting) return
|
|
94
|
+
|
|
95
|
+
authed = false
|
|
96
|
+
if (!disconnected) {
|
|
97
|
+
await new Promise(r => setTimeout(r, Math.min(1000, failedConnections * 100)))
|
|
98
|
+
ws.onmessage = () => {} // needs to be a no-op since a closing ws can still get messages
|
|
99
|
+
restarting = true
|
|
100
|
+
failedConnections += 1
|
|
101
|
+
initWS() // TODO: don't do this if we are purposefully unloading...
|
|
102
|
+
restarting = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function initWS() {
|
|
107
|
+
ws = new WebSocket(`${protocol}://${host}`)
|
|
108
|
+
|
|
109
|
+
ws.onopen = async () => {
|
|
110
|
+
if (!sessionMetrics.connected) sessionMetrics.connected = Date.now()
|
|
111
|
+
log('AUTHORIZING NEWLY OPENED WS FOR SESSION:', session)
|
|
112
|
+
failedConnections = 0
|
|
113
|
+
ws.send(JSON.stringify({ token: await token(), session }))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ws.onmessage = async ({ data }) => {
|
|
117
|
+
checkHeartbeat()
|
|
118
|
+
if (data.length === 0) return // heartbeat
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
log('handling message', disconnected, authed)
|
|
122
|
+
const message = JSON.parse(data)
|
|
123
|
+
log('message', JSON.stringify(message))
|
|
124
|
+
|
|
125
|
+
if (message.error) console.warn('ERROR RESPONSE', message)
|
|
126
|
+
|
|
127
|
+
if (!authed) {
|
|
128
|
+
// TODO: credential refresh flow instead of forcing login
|
|
129
|
+
if (message.error) return login()
|
|
130
|
+
|
|
131
|
+
authed = true
|
|
132
|
+
if (!user) { // this is the first authed websocket connection
|
|
133
|
+
sessionMetrics.authenticated = Date.now()
|
|
134
|
+
user = message.auth.user
|
|
135
|
+
session = message.session
|
|
136
|
+
server = message.server
|
|
137
|
+
|
|
138
|
+
// save session metrics
|
|
139
|
+
interact(session, [
|
|
140
|
+
{op: 'add', path: ['active', 'loaded'], value: sessionMetrics.loaded },
|
|
141
|
+
{op: 'add', path: ['active', 'connected'], value: sessionMetrics.connected },
|
|
142
|
+
{op: 'add', path: ['active', 'authenticated'], value: sessionMetrics.authenticated },
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
setEnvironment(message)
|
|
146
|
+
}
|
|
147
|
+
else if (server !== message.server) {
|
|
148
|
+
console.warn(`REBOOTING DUE TO SERVER SWITCH ${server} -> ${message.server}`)
|
|
149
|
+
reboot()
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
lastSentSI = message.ack
|
|
153
|
+
}
|
|
154
|
+
flushMessageQueue()
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
if (message.si !== undefined) {
|
|
158
|
+
if (responses[message.si]) {
|
|
159
|
+
// TODO: remove "acknowledged" messages from queue and do accounting with si
|
|
160
|
+
responses[message.si]
|
|
161
|
+
.forEach(([res, rej]) => message.error ? rej(message) : res(message))
|
|
162
|
+
|
|
163
|
+
delete responses[message.si]
|
|
164
|
+
ws.send(JSON.stringify({ack: message.si})) // acknowledgement that we have received the response for this message
|
|
165
|
+
if (Object.keys(responses).length === 0) resolveSyncPromises()
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// TODO: consider what to do here... probably want to throw error if in dev env
|
|
169
|
+
console.warn('received MULTIPLE responses for message with si', message.si, message)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const qualifiedScope = isUUID(message.scope) ? message.scope : `${ message.user === user ? '' : message.user}/${message.scope}`
|
|
174
|
+
if (watchers[qualifiedScope]) {
|
|
175
|
+
states[qualifiedScope] = await states[qualifiedScope]
|
|
176
|
+
|
|
177
|
+
const lastResetPatchIndex = message.patch.findLastIndex(p => p.path.length === 0)
|
|
178
|
+
if (lastResetPatchIndex > -1) states[qualifiedScope] = message.patch[lastResetPatchIndex].value
|
|
179
|
+
|
|
180
|
+
if (states[qualifiedScope].active === undefined) states[qualifiedScope].active = {}
|
|
181
|
+
applyPatch(states[qualifiedScope], standardJSONPatch(message.patch.slice(lastResetPatchIndex + 1)))
|
|
182
|
+
watchers[qualifiedScope]
|
|
183
|
+
.forEach(fn => {
|
|
184
|
+
const state = structuredClone(states[qualifiedScope].active)
|
|
185
|
+
fn({ ...message, state })
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.error('ERROR HANDLING WS MESSAGE', error)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ws.onerror = async error => {
|
|
197
|
+
log('WS CONNECTION ERROR', error.message)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
ws.onclose = async error => {
|
|
201
|
+
log('WS CLOSURE', error.message)
|
|
202
|
+
restartConnection()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
checkHeartbeat()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function synced() { return isSynced ? null : new Promise(res => syncedPromiseResolutions.push(res)) }
|
|
209
|
+
|
|
210
|
+
function lastMessageResponse() { return new Promise((res, rej) => responses[si].push([res, rej])) }
|
|
211
|
+
|
|
212
|
+
function disconnect() {
|
|
213
|
+
log('DISCONNECTED AGENT!!!!!!!!!!!!!!!')
|
|
214
|
+
disconnected = true
|
|
215
|
+
ws.close()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function reconnect() {
|
|
219
|
+
log('RECONNECTED AGENT!!!!!!!!!!!!!!!')
|
|
220
|
+
disconnected = false
|
|
221
|
+
restartConnection()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
initWS()
|
|
225
|
+
|
|
226
|
+
return [queueMessage, lastMessageResponse, disconnect, reconnect, synced]
|
|
227
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { v4 as uuid, validate as isUUID } from 'uuid'
|
|
2
|
+
import MutableProxy from '../../persistence/json.js'
|
|
3
|
+
|
|
4
|
+
const SUBSCRIPTION_TYPE = 'application/json;type=subscription'
|
|
5
|
+
|
|
6
|
+
export default function(scope='[]', user, { keyToSubscriptionId, watchers, states, create, environment, lastMessageResponse, lastInteractionResponse, tagIfNotYetTaggedInSession, interact }) {
|
|
7
|
+
let resolveMetadataPromise
|
|
8
|
+
let metadataPromise = new Promise(resolve => resolveMetadataPromise = resolve)
|
|
9
|
+
|
|
10
|
+
const statePromise = new Promise(async (resolveState, rejectState) => {
|
|
11
|
+
const qualifiedScope = isUUID(scope) ? scope : `${user || ''}/${scope}`
|
|
12
|
+
if (!keyToSubscriptionId[qualifiedScope]) {
|
|
13
|
+
const id = uuid()
|
|
14
|
+
|
|
15
|
+
keyToSubscriptionId[qualifiedScope] = id
|
|
16
|
+
watchers[qualifiedScope] = []
|
|
17
|
+
states[qualifiedScope] = new Promise(async (resolve, reject) => {
|
|
18
|
+
const { session } = await environment()
|
|
19
|
+
create({
|
|
20
|
+
id,
|
|
21
|
+
active_type: SUBSCRIPTION_TYPE,
|
|
22
|
+
active: { session, scope, user, ii: null, initialized: Date.now() },
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const state = await lastMessageResponse()
|
|
27
|
+
tagIfNotYetTaggedInSession('subscribed', state.id)
|
|
28
|
+
interact(id, [
|
|
29
|
+
{ op: 'add', path: ['active', 'ii'], value: state.ii }, // TODO: use state.ii when is coming down properly...
|
|
30
|
+
{ op: 'add', path: ['active', 'synced'], value: Date.now() }
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
resolve(state)
|
|
34
|
+
}
|
|
35
|
+
catch (error) { reject(error) }
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await lastInteractionResponse[qualifiedScope]
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const data = structuredClone(await states[qualifiedScope])
|
|
43
|
+
const active = data.active
|
|
44
|
+
delete data.active
|
|
45
|
+
resolveMetadataPromise(data)
|
|
46
|
+
resolveState(new MutableProxy(active || {}, patch => {
|
|
47
|
+
const activePatch = structuredClone(patch)
|
|
48
|
+
activePatch.forEach(entry => entry.path.unshift('active'))
|
|
49
|
+
interact(scope, activePatch)
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
rejectState(error)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
statePromise.metadata = metadataPromise
|
|
58
|
+
return statePromise
|
|
59
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { validate as isUUID } from 'uuid'
|
|
2
|
+
|
|
3
|
+
export default function({ metadata, state, watchers }) {
|
|
4
|
+
|
|
5
|
+
function watch(scope=DEFAULT_SCOPE_NAME, fn, user) {
|
|
6
|
+
if (Array.isArray(scope)) return watchResolution(scope, fn, user)
|
|
7
|
+
|
|
8
|
+
let initialSent = false
|
|
9
|
+
const queue = []
|
|
10
|
+
function cb(update) {
|
|
11
|
+
if (initialSent) fn(update)
|
|
12
|
+
else queue.push(update)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const statePromise = state(scope, user)
|
|
16
|
+
const qualifiedScope = isUUID(scope) ? scope : `${user || ''}/${scope}`
|
|
17
|
+
|
|
18
|
+
if (!watchers[qualifiedScope]) watchers[qualifiedScope] = []
|
|
19
|
+
watchers[qualifiedScope].push(cb)
|
|
20
|
+
|
|
21
|
+
metadata(scope, user)
|
|
22
|
+
.then(async ({ ii }) => {
|
|
23
|
+
fn({
|
|
24
|
+
scope,
|
|
25
|
+
state: await statePromise,
|
|
26
|
+
patch: null,
|
|
27
|
+
ii
|
|
28
|
+
})
|
|
29
|
+
initialSent = true
|
|
30
|
+
queue.forEach(fn)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return () => removeWatcher(qualifiedScope, cb)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function watchResolution(path, callback, user) {
|
|
37
|
+
const id = path[0]
|
|
38
|
+
const references = path.slice(1)
|
|
39
|
+
let unwatchDeeper = () => {}
|
|
40
|
+
|
|
41
|
+
const watchCallback = ({ state }) => {
|
|
42
|
+
if (references.length === 0) {
|
|
43
|
+
callback(state)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// TODO: check if value we care about actually changed
|
|
48
|
+
// and ignore this update if it has not.
|
|
49
|
+
unwatchDeeper()
|
|
50
|
+
|
|
51
|
+
let value = state
|
|
52
|
+
for (let index = 0; index < references.length; index += 1) {
|
|
53
|
+
value = value[references[index]]
|
|
54
|
+
if (
|
|
55
|
+
value === null ||
|
|
56
|
+
value === undefined ||
|
|
57
|
+
index === references.length - 1
|
|
58
|
+
) callback(value)
|
|
59
|
+
else if (isUUID(value)) {
|
|
60
|
+
unwatchDeeper = watchResolution([value, ...references.slice(index + 1)], callback, user)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const unwatch = watch(id, watchCallback, user)
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
unwatch()
|
|
70
|
+
unwatchDeeper()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removeWatcher(key, fn) {
|
|
75
|
+
const watcherIndex = watchers[key].findIndex(x => x === fn)
|
|
76
|
+
if (watcherIndex > -1) watchers[key].splice(watcherIndex, 1)
|
|
77
|
+
else console.warn('TRIED TO REMOVE WATCHER THAT DOES NOT EXIST')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [ watch, removeWatcher ]
|
|
81
|
+
|
|
82
|
+
}
|
package/agents/node.js
CHANGED
|
@@ -3,7 +3,7 @@ import WebSocket from 'ws'
|
|
|
3
3
|
import { v1 as uuid } from 'uuid'
|
|
4
4
|
import fastJSONPatch from 'fast-json-patch'
|
|
5
5
|
import fetch from 'node-fetch'
|
|
6
|
-
import Agent from './generic.js'
|
|
6
|
+
import Agent from './generic/index.js'
|
|
7
7
|
|
|
8
8
|
const { SERVE_HOST, SERVICE_ACCOUNT_TOKEN } = process.env
|
|
9
9
|
|
package/browser.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { default as GenericAgent } from './agents/generic.js'
|
|
1
|
+
export { default as GenericAgent } from './agents/generic/index.js'
|
|
2
2
|
export { default as browserAgent } from './agents/browser/initialize.js'
|
package/node.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default as NodeAgent } from '../agents/node.js'
|
|
2
|
-
export { default as GenericAgent } from '../agents/generic.js'
|
|
2
|
+
export { default as GenericAgent } from '../agents/generic/index.js'
|
package/package.json
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
:v-if="resolvedId"
|
|
4
4
|
:key="resolvedId"
|
|
5
5
|
:ref="el => setup(el, resolvedId)"
|
|
6
|
-
|
|
6
|
+
style="
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
9
|
+
border: none;
|
|
10
|
+
"
|
|
7
11
|
allow="camera;microphone"
|
|
8
12
|
/>
|
|
9
13
|
</template>
|
|
@@ -59,14 +63,3 @@ export default {
|
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
</script>
|
|
62
|
-
|
|
63
|
-
<style scoped>
|
|
64
|
-
|
|
65
|
-
.wrapper
|
|
66
|
-
{
|
|
67
|
-
width: 100%;
|
|
68
|
-
height: 100%;
|
|
69
|
-
border: none;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
</style>
|
package/agents/generic.js
DELETED
|
@@ -1,536 +0,0 @@
|
|
|
1
|
-
import MutableProxy from '../persistence/json.js'
|
|
2
|
-
import download from './download.js'
|
|
3
|
-
|
|
4
|
-
// TODO: consider using something better than name as mechanism
|
|
5
|
-
// for resoling default scope in context
|
|
6
|
-
const DEFAULT_SCOPE_NAME = '[]'
|
|
7
|
-
const HEARTBEAT_TIMEOUT = 10000
|
|
8
|
-
const SESSION_TYPE = 'application/json;type=session'
|
|
9
|
-
const UPLOAD_TYPE = 'application/json;type=upload'
|
|
10
|
-
const SUBSCRIPTION_TYPE = 'application/json;type=subscription'
|
|
11
|
-
const POSTGRES_QUERY_TYPE = 'application/json;type=postgres-query'
|
|
12
|
-
const TAG_TYPE = 'application/json;type=tag'
|
|
13
|
-
|
|
14
|
-
// transform our custom path implementation to the standard JSONPatch path
|
|
15
|
-
function standardJSONPatch(patch) {
|
|
16
|
-
return patch.map(p => {
|
|
17
|
-
return {...p, path: '/' + p.path.map(sanitizeJSONPatchPathSegment).join('/')}
|
|
18
|
-
})
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function isUUID(string) {
|
|
22
|
-
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(string)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function sanitizeJSONPatchPathSegment(s) {
|
|
26
|
-
if (typeof s === "string") return s.replaceAll('~', '~0').replaceAll('/', '~1')
|
|
27
|
-
else return s
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const sessionMetrics = {
|
|
31
|
-
loaded: Date.now(),
|
|
32
|
-
connected: null,
|
|
33
|
-
authenticated: null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export default function Agent({ host, token, WebSocket, protocol='ws', uuid, fetch, applyPatch, login, logout, reboot }) {
|
|
37
|
-
let ws
|
|
38
|
-
let user
|
|
39
|
-
let domain
|
|
40
|
-
let session
|
|
41
|
-
let server
|
|
42
|
-
let isSynced = false
|
|
43
|
-
let si = -1
|
|
44
|
-
let authed = false
|
|
45
|
-
const states = {}
|
|
46
|
-
const responses = {}
|
|
47
|
-
const watchers = {}
|
|
48
|
-
const keyToSubscriptionId = {}
|
|
49
|
-
const lastInteractionResponse = {}
|
|
50
|
-
const tagTypeToTargetCache = {}
|
|
51
|
-
const messageQueue = []
|
|
52
|
-
let resolveEnvironment
|
|
53
|
-
let disconnected = false
|
|
54
|
-
let failedConnections = 0
|
|
55
|
-
let mode = 'normal'
|
|
56
|
-
const environmentPromise = new Promise(r => resolveEnvironment = r)
|
|
57
|
-
let lastSentSI = -1
|
|
58
|
-
let lastHeartbeat
|
|
59
|
-
let lastSynchronousScopePatched = null
|
|
60
|
-
let lastSynchronousScopePatchPromise = null
|
|
61
|
-
const syncedPromiseResolutions = []
|
|
62
|
-
|
|
63
|
-
const patches = state('patches')
|
|
64
|
-
|
|
65
|
-
log('INITIALIZING AGENT CONNECTION')
|
|
66
|
-
initWS()
|
|
67
|
-
|
|
68
|
-
function log() {
|
|
69
|
-
if (mode === 'debug') console.log(...arguments)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function resolveSyncPromises() {
|
|
73
|
-
while (syncedPromiseResolutions.length) syncedPromiseResolutions.shift()()
|
|
74
|
-
isSynced = true
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function removeWatcher(key, fn) {
|
|
78
|
-
const watcherIndex = watchers[key].findIndex(x => x === fn)
|
|
79
|
-
if (watcherIndex > -1) watchers[key].splice(watcherIndex, 1)
|
|
80
|
-
else console.warn('TRIED TO REMOVE WATCHER THAT DOES NOT EXIST')
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// TODO: clear acknowledged messages
|
|
84
|
-
async function flushMessageQueue() {
|
|
85
|
-
// this makes flushing async, giving time for queue message to combine synchronous updates
|
|
86
|
-
await new Promise(resolve => resolve())
|
|
87
|
-
lastSynchronousScopePatched = null
|
|
88
|
-
|
|
89
|
-
while (authed && ws.readyState === WebSocket.OPEN && lastSentSI+1 < messageQueue.length) {
|
|
90
|
-
lastSynchronousScopePatched = null
|
|
91
|
-
lastSentSI += 1
|
|
92
|
-
ws.send(JSON.stringify(messageQueue[lastSentSI]))
|
|
93
|
-
|
|
94
|
-
// async so we don't try and push more to a closed connection
|
|
95
|
-
await new Promise(resolve => resolve())
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function lastMessageResponse() { // TODO: handle error responses
|
|
100
|
-
return new Promise((resolve, reject) => responses[si].push([resolve, reject]))
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function queueMessage({ scope, patch }) {
|
|
104
|
-
isSynced = false
|
|
105
|
-
if (lastSynchronousScopePatched === scope) {
|
|
106
|
-
const i = messageQueue.length - 1
|
|
107
|
-
messageQueue[i].patch = [...messageQueue[i].patch, ...patch]
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
si += 1
|
|
111
|
-
lastSynchronousScopePatchPromise = new Promise((resolve, reject) => responses[si] = [[resolve, reject]])
|
|
112
|
-
messageQueue.push({ scope, patch, si, ts: Date.now()})
|
|
113
|
-
lastSynchronousScopePatched = scope
|
|
114
|
-
flushMessageQueue()
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return lastSynchronousScopePatchPromise
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function checkHeartbeat() {
|
|
121
|
-
clearTimeout(lastHeartbeat)
|
|
122
|
-
lastHeartbeat = setTimeout(
|
|
123
|
-
() => {
|
|
124
|
-
log('CLOSING DUE TO HEARTBEAT TIMEOUT')
|
|
125
|
-
restartConnection()
|
|
126
|
-
},
|
|
127
|
-
HEARTBEAT_TIMEOUT
|
|
128
|
-
)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
let restarting = false
|
|
132
|
-
async function restartConnection() {
|
|
133
|
-
if (restarting) return
|
|
134
|
-
|
|
135
|
-
authed = false
|
|
136
|
-
if (!disconnected) {
|
|
137
|
-
await new Promise(r => setTimeout(r, Math.min(1000, failedConnections * 100)))
|
|
138
|
-
ws.onmessage = () => {} // needs to be a no-op since a closing ws can still get messages
|
|
139
|
-
restarting = true
|
|
140
|
-
failedConnections += 1
|
|
141
|
-
initWS() // TODO: don't do this if we are purposefully unloading...
|
|
142
|
-
restarting = false
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function initWS() {
|
|
147
|
-
ws = new WebSocket(`${protocol}://${host}`)
|
|
148
|
-
|
|
149
|
-
ws.onopen = async () => {
|
|
150
|
-
if (!sessionMetrics.connected) sessionMetrics.connected = Date.now()
|
|
151
|
-
log('AUTHORIZING NEWLY OPENED WS FOR SESSION:', session)
|
|
152
|
-
failedConnections = 0
|
|
153
|
-
ws.send(JSON.stringify({ token: await token(), session }))
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
ws.onmessage = async ({ data }) => {
|
|
157
|
-
checkHeartbeat()
|
|
158
|
-
if (data.length === 0) return // heartbeat
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
log('handling message', disconnected, authed)
|
|
162
|
-
const message = JSON.parse(data)
|
|
163
|
-
if (mode === 'debug') log('message', JSON.stringify(message))
|
|
164
|
-
|
|
165
|
-
if (message.error) console.warn('ERROR RESPONSE', message)
|
|
166
|
-
|
|
167
|
-
if (!authed) {
|
|
168
|
-
// TODO: credential refresh flow instead of forcing login
|
|
169
|
-
if (message.error) return login()
|
|
170
|
-
|
|
171
|
-
authed = true
|
|
172
|
-
if (!user) { // this is the first authed websocket connection
|
|
173
|
-
sessionMetrics.authenticated = Date.now()
|
|
174
|
-
user = message.auth.user
|
|
175
|
-
session = message.session
|
|
176
|
-
domain = message.domain
|
|
177
|
-
server = message.server
|
|
178
|
-
|
|
179
|
-
// save session metrics
|
|
180
|
-
interact(session, [
|
|
181
|
-
{op: 'add', path: ['active', 'loaded'], value: sessionMetrics.loaded },
|
|
182
|
-
{op: 'add', path: ['active', 'connected'], value: sessionMetrics.connected },
|
|
183
|
-
{op: 'add', path: ['active', 'authenticated'], value: sessionMetrics.authenticated },
|
|
184
|
-
])
|
|
185
|
-
resolveEnvironment(message)
|
|
186
|
-
}
|
|
187
|
-
else if (server !== message.server) {
|
|
188
|
-
console.warn(`REBOOTING DUE TO SERVER SWITCH ${server} -> ${message.server}`)
|
|
189
|
-
reboot()
|
|
190
|
-
}
|
|
191
|
-
else {
|
|
192
|
-
lastSentSI = message.ack
|
|
193
|
-
}
|
|
194
|
-
flushMessageQueue()
|
|
195
|
-
}
|
|
196
|
-
else {
|
|
197
|
-
if (message.si !== undefined) {
|
|
198
|
-
if (responses[message.si]) {
|
|
199
|
-
// TODO: remove "acknowledged" messages from queue and do accounting with si
|
|
200
|
-
responses[message.si]
|
|
201
|
-
.forEach(([resolve, reject]) => {
|
|
202
|
-
message.error ? reject(message) : resolve(message)
|
|
203
|
-
})
|
|
204
|
-
delete responses[message.si]
|
|
205
|
-
ws.send(JSON.stringify({ack: message.si})) // acknowledgement that we have received the response for this message
|
|
206
|
-
if (Object.keys(responses).length === 0) {
|
|
207
|
-
resolveSyncPromises()
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
// TODO: consider what to do here... probably want to throw error if in dev env
|
|
212
|
-
console.warn('received MULTIPLE responses for message with si', message.si, message)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
if (watchers[message.scope]) {
|
|
217
|
-
states[message.scope] = await states[message.scope]
|
|
218
|
-
|
|
219
|
-
const lastResetPatchIndex = message.patch.findLastIndex(p => p.path.length === 0)
|
|
220
|
-
if (lastResetPatchIndex > -1) states[message.scope] = message.patch[lastResetPatchIndex].value
|
|
221
|
-
|
|
222
|
-
if (states[message.scope].active === undefined) states[message.scope].active = {}
|
|
223
|
-
applyPatch(states[message.scope], standardJSONPatch(message.patch.slice(lastResetPatchIndex + 1)))
|
|
224
|
-
watchers[message.scope]
|
|
225
|
-
.forEach(fn => {
|
|
226
|
-
const state = structuredClone(states[message.scope].active)
|
|
227
|
-
fn({ ...message, state })
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
catch (error) {
|
|
234
|
-
console.error('ERROR HANDLING WS MESSAGE', error)
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
ws.onerror = async error => {
|
|
239
|
-
log('WS CONNECTION ERROR', error.message)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
ws.onclose = async error => {
|
|
243
|
-
log('WS CLOSURE', error.message)
|
|
244
|
-
restartConnection()
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
checkHeartbeat()
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function create({ id=uuid(), active_type, active, name }) {
|
|
251
|
-
// TODO: collapse into 1 patch and 1 interact call
|
|
252
|
-
// (requires updating side effects)
|
|
253
|
-
const patch = [
|
|
254
|
-
{ op: 'add', path: ['active_type'], value: active_type },
|
|
255
|
-
{ op: 'add', path: ['active'], value: active }
|
|
256
|
-
]
|
|
257
|
-
interact(id, patch, false)
|
|
258
|
-
return id
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
async function tagIfNotYetTaggedInSession(tag_type, target) {
|
|
262
|
-
const targetCache = tagTypeToTargetCache[tag_type]
|
|
263
|
-
if (targetCache && targetCache[target]) return
|
|
264
|
-
|
|
265
|
-
if (!targetCache) tagTypeToTargetCache[tag_type] = {}
|
|
266
|
-
if (tagTypeToTargetCache[tag_type][target]) return
|
|
267
|
-
|
|
268
|
-
tagTypeToTargetCache[tag_type][target] = true
|
|
269
|
-
|
|
270
|
-
// always use absolute referene when tagging
|
|
271
|
-
if (!isUUID(target)) target = (await metadata(target)).id
|
|
272
|
-
|
|
273
|
-
await tag(tag_type, target)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function environment() {
|
|
277
|
-
return { ...(await environmentPromise), context: [] }
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function state(scope='[]') {
|
|
281
|
-
tagIfNotYetTaggedInSession('subscribed', scope)
|
|
282
|
-
return new Promise(async (resolveState, rejectState) => {
|
|
283
|
-
if (!keyToSubscriptionId[scope]) {
|
|
284
|
-
const id = uuid()
|
|
285
|
-
|
|
286
|
-
keyToSubscriptionId[scope] = id
|
|
287
|
-
watchers[scope] = []
|
|
288
|
-
states[scope] = new Promise(async (resolve, reject) => {
|
|
289
|
-
create({
|
|
290
|
-
id,
|
|
291
|
-
active_type: SUBSCRIPTION_TYPE,
|
|
292
|
-
active: { session, scope, ii: null, initialized: Date.now() },
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
const state = await lastMessageResponse()
|
|
297
|
-
interact(id, [
|
|
298
|
-
{ op: 'add', path: ['active', 'ii'], value: 1 }, // TODO: use state.ii when is coming down properly...
|
|
299
|
-
{ op: 'add', path: ['active', 'synced'], value: Date.now() }
|
|
300
|
-
])
|
|
301
|
-
|
|
302
|
-
resolve(state)
|
|
303
|
-
}
|
|
304
|
-
catch (error) { reject(error) }
|
|
305
|
-
})
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
await lastInteractionResponse[scope]
|
|
309
|
-
|
|
310
|
-
try {
|
|
311
|
-
const state = structuredClone(await states[scope])
|
|
312
|
-
|
|
313
|
-
resolveState(new MutableProxy(state.active || {}, patch => {
|
|
314
|
-
const activePatch = structuredClone(patch)
|
|
315
|
-
activePatch.forEach(entry => entry.path.unshift('active'))
|
|
316
|
-
interact(scope, activePatch)
|
|
317
|
-
}))
|
|
318
|
-
}
|
|
319
|
-
catch (error) {
|
|
320
|
-
rejectState(error)
|
|
321
|
-
}
|
|
322
|
-
})
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function watchResolution(path, callback) {
|
|
326
|
-
const id = path[0]
|
|
327
|
-
const references = path.slice(1)
|
|
328
|
-
let unwatchDeeper = () => {}
|
|
329
|
-
|
|
330
|
-
const unwatch = watch(id, ({ state }) => {
|
|
331
|
-
if (references.length === 0) {
|
|
332
|
-
callback(state)
|
|
333
|
-
return
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// TODO: check if value we care about actually changed
|
|
337
|
-
// and ignore this update if it has not.
|
|
338
|
-
unwatchDeeper()
|
|
339
|
-
|
|
340
|
-
let value = state
|
|
341
|
-
for (let index = 0; index < references.length; index += 1) {
|
|
342
|
-
value = value[references[index]]
|
|
343
|
-
if (
|
|
344
|
-
value === null ||
|
|
345
|
-
value === undefined ||
|
|
346
|
-
index === references.length - 1
|
|
347
|
-
) callback(value)
|
|
348
|
-
else if (isUUID(value)) {
|
|
349
|
-
unwatchDeeper = watchResolution([value, ...references.slice(index + 1)], callback)
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
return () => {
|
|
356
|
-
unwatch()
|
|
357
|
-
unwatchDeeper()
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function watch(scope=DEFAULT_SCOPE_NAME, fn) {
|
|
362
|
-
if (Array.isArray(scope)) return watchResolution(scope, fn)
|
|
363
|
-
|
|
364
|
-
let initialSent = false
|
|
365
|
-
const queue = []
|
|
366
|
-
function cb(update) {
|
|
367
|
-
if (initialSent) fn(update)
|
|
368
|
-
else queue.push(update)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const statePromise = state(scope)
|
|
372
|
-
if (!watchers[scope]) watchers[scope] = []
|
|
373
|
-
watchers[scope].push(cb)
|
|
374
|
-
|
|
375
|
-
metadata(scope)
|
|
376
|
-
.then(async ({ ii }) => {
|
|
377
|
-
fn({
|
|
378
|
-
scope,
|
|
379
|
-
state: await statePromise,
|
|
380
|
-
patch: null,
|
|
381
|
-
ii
|
|
382
|
-
})
|
|
383
|
-
initialSent = true
|
|
384
|
-
queue.forEach(fn)
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
return () => removeWatcher(scope, cb)
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// TODO: if no data, set up streaming upload
|
|
391
|
-
async function upload(name, type, data, id=uuid()) {
|
|
392
|
-
// TODO: include data size info...
|
|
393
|
-
create({
|
|
394
|
-
active_type: UPLOAD_TYPE,
|
|
395
|
-
active: { id, type }
|
|
396
|
-
})
|
|
397
|
-
const { url } = await lastMessageResponse()
|
|
398
|
-
|
|
399
|
-
if (data === undefined) return url
|
|
400
|
-
else {
|
|
401
|
-
const headers = { 'Content-Type': type }
|
|
402
|
-
const response = await fetch(url, {method: 'PUT', headers, body: data})
|
|
403
|
-
const { ok, statusText } = response
|
|
404
|
-
|
|
405
|
-
if (ok) return id
|
|
406
|
-
else throw new Error(statusText)
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async function patch(root, scopes) {
|
|
411
|
-
patches[uuid()] = { root, scopes }
|
|
412
|
-
const { swaps } = await lastMessageResponse()
|
|
413
|
-
return { swaps }
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// TODO: addTag option should probably not be exposed
|
|
417
|
-
async function interact(scope=DEFAULT_SCOPE_NAME, patch, addTag=true) {
|
|
418
|
-
if (addTag) tagIfNotYetTaggedInSession('mutated', scope)
|
|
419
|
-
// TODO: ensure user is owner of scope
|
|
420
|
-
const response = queueMessage({scope, patch})
|
|
421
|
-
|
|
422
|
-
// if we are watching this scope, we want to keep track of last interaction we fired
|
|
423
|
-
if (states[scope] !== undefined) {
|
|
424
|
-
let resolve
|
|
425
|
-
lastInteractionResponse[scope] = new Promise(r => resolve = r)
|
|
426
|
-
|
|
427
|
-
const resolveAndUnwatch = async (update) => {
|
|
428
|
-
const { ii } = await response
|
|
429
|
-
if (update.ii === ii) {
|
|
430
|
-
resolve(ii)
|
|
431
|
-
removeWatcher(scope, resolveAndUnwatch)
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
watchers[scope].push(resolveAndUnwatch)
|
|
436
|
-
|
|
437
|
-
return response
|
|
438
|
-
}
|
|
439
|
-
else {
|
|
440
|
-
const { ii } = await response
|
|
441
|
-
return { ii }
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
async function claim(domain) {
|
|
446
|
-
return interact('claims', [{ op: 'add', path: ['active', domain], value: null }])
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function reset(scope=DEFAULT_SCOPE_NAME) {
|
|
450
|
-
return interact(scope, [{ op: 'remove', path:['active'] }])
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function isValidMetadataMutation({ path, op, value }) {
|
|
454
|
-
return (
|
|
455
|
-
['active_type', 'name'].includes(path[0])
|
|
456
|
-
&& path.length === 1
|
|
457
|
-
&& typeof value === 'string' || op === 'remove'
|
|
458
|
-
)
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
async function metadata(id=DEFAULT_SCOPE_NAME) {
|
|
462
|
-
// TODO: handle when id is undefined (default like state call?)
|
|
463
|
-
await state(id)
|
|
464
|
-
const md = structuredClone(await states[id])
|
|
465
|
-
delete md.active
|
|
466
|
-
return new MutableProxy(md, patch => {
|
|
467
|
-
const activePatch = structuredClone(patch)
|
|
468
|
-
activePatch.forEach(entry => {
|
|
469
|
-
if (!isValidMetadataMutation(entry)) throw new Error('You may only modify the type or name for a scope\'s metadata')
|
|
470
|
-
})
|
|
471
|
-
interact(id, activePatch)
|
|
472
|
-
})
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
async function synced() {
|
|
476
|
-
return isSynced ? null : new Promise(resolve => syncedPromiseResolutions.push(resolve))
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function disconnect() {
|
|
480
|
-
log('DISCONNECTED AGENT!!!!!!!!!!!!!!!')
|
|
481
|
-
disconnected = true
|
|
482
|
-
ws.close()
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function reconnect() {
|
|
486
|
-
log('RECONNECTED AGENT!!!!!!!!!!!!!!!')
|
|
487
|
-
disconnected = false
|
|
488
|
-
restartConnection()
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function debug() {
|
|
492
|
-
mode = 'debug'
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
async function query(query, params, domain) {
|
|
496
|
-
const id = uuid()
|
|
497
|
-
create({
|
|
498
|
-
id,
|
|
499
|
-
active_type: POSTGRES_QUERY_TYPE,
|
|
500
|
-
active: { query, params, domain, requested: Date.now() },
|
|
501
|
-
})
|
|
502
|
-
const { rows } = await lastMessageResponse()
|
|
503
|
-
interact(id, [{ op: 'add', path: ['active', 'responded'], value: Date.now() }])
|
|
504
|
-
return rows
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function tag(tag_type, target, context=[]) {
|
|
508
|
-
return create({
|
|
509
|
-
active_type: TAG_TYPE,
|
|
510
|
-
active: { tag_type, target, context }
|
|
511
|
-
})
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
return {
|
|
515
|
-
uuid,
|
|
516
|
-
environment,
|
|
517
|
-
login,
|
|
518
|
-
logout,
|
|
519
|
-
create,
|
|
520
|
-
state,
|
|
521
|
-
watch,
|
|
522
|
-
upload,
|
|
523
|
-
download: id => download(id, { create, lastMessageResponse, fetch, metadata }),
|
|
524
|
-
interact,
|
|
525
|
-
patch,
|
|
526
|
-
claim,
|
|
527
|
-
reset,
|
|
528
|
-
metadata,
|
|
529
|
-
query,
|
|
530
|
-
synced,
|
|
531
|
-
disconnect,
|
|
532
|
-
reconnect,
|
|
533
|
-
tag,
|
|
534
|
-
debug
|
|
535
|
-
}
|
|
536
|
-
}
|