@knowlearning/agents 0.9.24 → 0.9.26

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.
@@ -38,22 +38,17 @@ export default function EmbeddedAgent() {
38
38
  }
39
39
 
40
40
  addEventListener('message', async ({ data }) => {
41
- if (data.type === 'auth') {
42
- // TODO: switch to access_token
43
- if (localStorage.getItem('state') === data.state) {
44
- localStorage.setItem('token', data.token)
45
- }
46
- send({ type: 'close' })
47
- }
48
- else if (data.type === 'setup') resolveSession(data.session)
41
+ if (data.type === 'setup') resolveSession(data.session)
49
42
  else if (responses[data.requestId]) {
50
43
  const { resolve, reject } = responses[data.requestId]
51
44
  if (data.error) reject(data.error)
52
45
  else resolve(data.response)
53
46
  }
54
47
  else if (data.ii !== undefined) {
55
- const { scope } = data
56
- if (watchers[scope]) watchers[scope].forEach(fn => fn(data))
48
+ const { scope, user } = data
49
+ const { auth } = await environment()
50
+ const key = isUUID(scope) ? scope : `${!user || auth.user === user ? '' : user}/${scope}`
51
+ if (watchers[key]) watchers[key].forEach(fn => fn(data))
57
52
  }
58
53
  })
59
54
 
@@ -83,12 +78,13 @@ export default function EmbeddedAgent() {
83
78
  await tag(tag_type, target)
84
79
  }
85
80
 
86
-
87
- function watch(id, fn) {
81
+ function watch(id, fn, user) {
88
82
  tagIfNotYetTaggedInSession('subscribed', id)
89
- if (!watchers[id]) watchers[id] = []
90
- watchers[id].push(fn)
91
- return () => removeWatcher(id, fn)
83
+ const key = isUUID(id) ? id : `${ user || ''}/${id}`
84
+ if (!watchers[key]) watchers[key] = []
85
+ watchers[key].push(fn)
86
+ send({ type: 'state', scope: id, user })
87
+ return () => removeWatcher(key, fn)
92
88
  }
93
89
 
94
90
  async function patch(root, scopes) {
@@ -108,7 +104,6 @@ export default function EmbeddedAgent() {
108
104
  activePatch.forEach(entry => entry.path.unshift('active'))
109
105
  interact(scope, activePatch)
110
106
  })
111
-
112
107
  }
113
108
 
114
109
  function reset(scope) {
@@ -71,7 +71,8 @@ function embed(environment, iframe) {
71
71
  sendDown({}) // TODO: might want to send down the interaction index
72
72
  }
73
73
  else if (type === 'metadata') {
74
- sendDown(await Agent.metadata(message.scope))
74
+ const { scope, user } = message
75
+ sendDown(await Agent.metadata(scope, user))
75
76
  }
76
77
  else if (type === 'tag') {
77
78
  const { tag_type, target, context } = message
@@ -79,11 +80,14 @@ function embed(environment, iframe) {
79
80
  sendDown(await Agent.tag(tag_type, target, prependedContext))
80
81
  }
81
82
  else if (type === 'state') {
82
- const { scope } = message
83
+ const { scope, user } = message
83
84
 
84
- const statePromise = Agent.state(scope)
85
+ const statePromise = Agent.state(scope, user)
85
86
 
86
- if (!watchers[scope]) watchers[scope] = Agent.watch(scope, postMessage)
87
+ const key = `${user || ''}/${scope}`
88
+ if (!watchers[key]) {
89
+ watchers[key] = Agent.watch(scope, postMessage, user)
90
+ }
87
91
 
88
92
  if (listeners.state) listeners.state({ scope })
89
93
  sendDown(await statePromise)
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { applyPatch } from 'https://esm.sh/fast-json-patch@3.1.1'
2
- import Agent from './generic.js'
2
+ import Agent from './generic/index.js'
3
3
 
4
4
  const SERVE_HOST = Deno.env.get("SERVE_HOST")
5
5
  const SERVICE_ACCOUNT_TOKEN = Deno.env.get("SERVICE_ACCOUNT_TOKEN")
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowlearning/agents",
3
- "version": "0.9.24",
3
+ "version": "0.9.26",
4
4
  "description": "API for embedding applications in KnowLearning systems.",
5
5
  "main": "node.js",
6
6
  "browser": "browser.js",
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
- }