heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/ai-vm.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// `quilt ai-vm`: the one process on a person's cloud AI machine (docs/superpowers/specs/
|
|
2
|
+
// 2026-10-17-ai-vm.md). It signs in to Quilt as the person's agent with the one-time link the
|
|
3
|
+
// machine was started with (its keys are made and kept here only), takes its settings and what
|
|
4
|
+
// waits for it from the accounts API (routes/ai-vm.js), signs the person's AI tool in with the
|
|
5
|
+
// tool's own login (ai-vm-signin.js), joins the sessions the person joins, and runs "Work on its
|
|
6
|
+
// own" (autowork.js) there as "<person>'s AI". With nothing to do for a while it asks to be
|
|
7
|
+
// suspended; anything for it resumes the machine where it was.
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import http from 'node:http'
|
|
11
|
+
import { spawn as spawnChild, execFile } from 'node:child_process'
|
|
12
|
+
import { quiltHome } from './legacy.js'
|
|
13
|
+
import { saveSettings, relayUrl } from './settings.js'
|
|
14
|
+
import { parseInvite } from './ui/invite.js'
|
|
15
|
+
import { SIGN_IN, readSignIn, keepToken, keptToken, statusSaysSignedIn } from './ai-vm-signin.js'
|
|
16
|
+
import { tokensOn } from './autowork.js'
|
|
17
|
+
import { writePrivateJson } from './private-file.js'
|
|
18
|
+
|
|
19
|
+
export const AGENT_NAME = 'cloud-ai'
|
|
20
|
+
export const IDLE_MS = 10 * 60 * 1000
|
|
21
|
+
const REPORT_MS = 60 * 1000
|
|
22
|
+
const RETRY_MS = 5 * 1000
|
|
23
|
+
const SIGN_IN_MS = 15 * 60 * 1000 // a sign-in nobody finishes is stopped
|
|
24
|
+
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs a tool's login in a terminal (the tools draw their sign-in for one), so on Linux under
|
|
28
|
+
* `script`; elsewhere (tests, a computer) with plain pipes. Returns { onData, write, kill, exited }.
|
|
29
|
+
*/
|
|
30
|
+
export function spawnLogin (exe, args, { env = {}, useScript = process.platform === 'linux' } = {}) {
|
|
31
|
+
const quote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`
|
|
32
|
+
// A wide terminal, so a long link is printed on one line.
|
|
33
|
+
const [cmd, argv] = useScript
|
|
34
|
+
? ['script', ['-q', '-f', '-e', '-c', `stty cols 2000 rows 50 2>/dev/null; exec ${[exe, ...args].map(quote).join(' ')}`, '/dev/null']]
|
|
35
|
+
: [exe, args]
|
|
36
|
+
const child = spawnChild(cmd, argv, { env: { ...process.env, ...env, TERM: 'xterm-256color', BROWSER: 'echo' }, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
37
|
+
const listeners = []
|
|
38
|
+
const onChunk = (d) => { for (const l of listeners) l(String(d)) }
|
|
39
|
+
child.stdout.on('data', onChunk)
|
|
40
|
+
child.stderr.on('data', onChunk)
|
|
41
|
+
const exited = new Promise((resolve) => { child.on('error', () => resolve(-1)); child.on('close', (code) => resolve(code ?? -1)) })
|
|
42
|
+
return {
|
|
43
|
+
onData: (fn) => listeners.push(fn),
|
|
44
|
+
write: (text) => { try { child.stdin.write(text) } catch {} },
|
|
45
|
+
kill: () => { try { child.kill() } catch {} },
|
|
46
|
+
exited
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A command's exit code and output (for the tools' status commands); -1 when it can't run. */
|
|
51
|
+
function runQuiet (exe, args) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
execFile(exe, args, { timeout: 30_000, env: process.env }, (err, stdout, stderr) => resolve({ code: err ? (typeof err.code === 'number' ? err.code : -1) : 0, output: `${stdout || ''}${stderr || ''}` }))
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class AiVm {
|
|
58
|
+
/**
|
|
59
|
+
* `api`: the accounts API. `home`: the machine's home folder (on its volume). Everything that
|
|
60
|
+
* reaches outside is injectable for tests: `fetch`, `login` (spawnLogin), `status` (runQuiet),
|
|
61
|
+
* `join` (runSession), `agentJoin`/`agentAccess` (agent-join.js), `autowork`, `which`.
|
|
62
|
+
*/
|
|
63
|
+
constructor ({ api, joinLink = '', home = process.env.HOME, fetch: fetchImpl = globalThis.fetch, login = spawnLogin, status = runQuiet, join = null, agent = null, autowork = null, which = null, now = Date.now, log = console.log, idleMs = IDLE_MS, reportMs = REPORT_MS, port = 8080, waitParam = '1' }) {
|
|
64
|
+
this.api = String(api || '').replace(/\/+$/, '')
|
|
65
|
+
this.joinLink = joinLink
|
|
66
|
+
this.home = home
|
|
67
|
+
this.fetch = fetchImpl
|
|
68
|
+
this.login = login
|
|
69
|
+
this.status = status
|
|
70
|
+
this.joinImpl = join
|
|
71
|
+
this.agentImpl = agent
|
|
72
|
+
this.autoworkImpl = autowork
|
|
73
|
+
this.which = which || ((names) => names[0])
|
|
74
|
+
this.now = now
|
|
75
|
+
this.log = log
|
|
76
|
+
this.idleMs = idleMs
|
|
77
|
+
this.reportMs = reportMs
|
|
78
|
+
this.port = port
|
|
79
|
+
this.waitParam = waitParam
|
|
80
|
+
this.config = null
|
|
81
|
+
this.signedIn = false
|
|
82
|
+
this.signIn = null // the step the person sees
|
|
83
|
+
this.signing = null // the login running now
|
|
84
|
+
this.runs = new Map() // room -> { run, link }
|
|
85
|
+
this.lastActivity = now()
|
|
86
|
+
this.stopped = false
|
|
87
|
+
this.timers = []
|
|
88
|
+
this.dir = path.join(quiltHome(), 'ai-vm')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ------------------------------------------------------------ the API --
|
|
92
|
+
|
|
93
|
+
async agent () {
|
|
94
|
+
if (!this.agentImpl) {
|
|
95
|
+
const m = await import('./agent-join.js')
|
|
96
|
+
this.agentImpl = { join: (o) => m.agentJoin({ ...o, fetch: this.fetch }), access: (o) => m.agentAccess({ ...o, fetch: this.fetch }), readAgent: m.readAgent }
|
|
97
|
+
}
|
|
98
|
+
return this.agentImpl
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Joins Quilt as the person's agent with the machine's one-time link, unless it already has. */
|
|
102
|
+
async ensureAgent () {
|
|
103
|
+
const a = await this.agent()
|
|
104
|
+
try { a.readAgent({ name: AGENT_NAME }); return } catch {}
|
|
105
|
+
if (!this.joinLink) throw new Error('This machine has no Quilt agent and no link to join with (QUILT_AI_VM_JOIN).')
|
|
106
|
+
await a.join({ link: this.joinLink, name: AGENT_NAME, provider: 'Quilt cloud AI', type: 'cloud AI', description: "A person's AI on its own cloud machine", log: (l) => this.log(l) })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async call (method, route, body) {
|
|
110
|
+
const a = await this.agent()
|
|
111
|
+
const saved = await a.access({ name: AGENT_NAME })
|
|
112
|
+
const res = await this.fetch(`${this.api}${route}`, {
|
|
113
|
+
method,
|
|
114
|
+
headers: { authorization: `Bearer ${saved.accessKey}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
115
|
+
body: body ? JSON.stringify(body) : undefined
|
|
116
|
+
})
|
|
117
|
+
const data = await res.json().catch(() => null)
|
|
118
|
+
if (!res.ok) throw Object.assign(new Error((data && data.error) || `the API answered ${res.status}`), { status: res.status })
|
|
119
|
+
return data
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ------------------------------------------------------------- startup --
|
|
123
|
+
|
|
124
|
+
async start () {
|
|
125
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
126
|
+
await this.ensureAgent()
|
|
127
|
+
// A kept Claude token signs Claude's runs in (it never leaves this machine).
|
|
128
|
+
const token = keptToken(this.home, 'Claude Code')
|
|
129
|
+
if (token) process.env.CLAUDE_CODE_OAUTH_TOKEN = token
|
|
130
|
+
if (this.port !== null) await this.serveHealth()
|
|
131
|
+
await this.takeNext(false)
|
|
132
|
+
for (const [room, link] of Object.entries(this.followed())) await this.follow(link).catch((err) => this.log(`could not rejoin ${room}: ${err.message}`))
|
|
133
|
+
await this.report().catch(() => {})
|
|
134
|
+
this.loop()
|
|
135
|
+
this.timers.push(setInterval(() => { this.report().catch(() => {}) }, this.reportMs))
|
|
136
|
+
this.timers.push(setInterval(() => { this.idleCheck().catch(() => {}) }, Math.min(30_000, Math.max(50, this.idleMs / 4))))
|
|
137
|
+
for (const t of this.timers) t.unref?.()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async stop () {
|
|
141
|
+
this.stopped = true
|
|
142
|
+
for (const t of this.timers) clearInterval(t)
|
|
143
|
+
if (this.signing) this.signing.kill()
|
|
144
|
+
for (const { run } of this.runs.values()) await run.stop().catch(() => {})
|
|
145
|
+
this.runs.clear()
|
|
146
|
+
if (this.health) await new Promise((resolve) => this.health.close(resolve))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Asks the API for what waits, over and over; a failure waits a moment and asks again. */
|
|
150
|
+
async loop () {
|
|
151
|
+
while (!this.stopped) {
|
|
152
|
+
try { await this.takeNext(true) } catch (err) {
|
|
153
|
+
if (this.stopped) return
|
|
154
|
+
this.log(`could not reach Quilt: ${err.message}`)
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_MS))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One look at what waits: settings, a sign-in to start, a code, sessions to join or leave. */
|
|
161
|
+
async takeNext (wait) {
|
|
162
|
+
const n = await this.call('GET', `/v1/ai-vm/next${wait ? `?wait=${this.waitParam}` : ''}`)
|
|
163
|
+
if (this.stopped) return n
|
|
164
|
+
const changedTool = !this.config || this.config.tool !== n.config.tool
|
|
165
|
+
this.applyConfig(n.config)
|
|
166
|
+
const busy = n.signIn || n.code || n.follow.length || n.leave.length
|
|
167
|
+
if (busy) this.lastActivity = this.now()
|
|
168
|
+
if (changedTool) {
|
|
169
|
+
this.signedIn = await this.checkSignedIn()
|
|
170
|
+
this.signIn = null
|
|
171
|
+
// A machine that isn't signed in starts the sign-in by itself, so the app has a link to show.
|
|
172
|
+
if (!this.signedIn && !n.signIn) await this.startSignIn()
|
|
173
|
+
}
|
|
174
|
+
if (n.signIn) await this.startSignIn()
|
|
175
|
+
if (n.code) this.giveCode(n.code)
|
|
176
|
+
for (const link of n.follow) await this.follow(link).catch((err) => this.log(`could not join: ${err.message}`))
|
|
177
|
+
for (const room of n.leave) await this.leave(room).catch((err) => this.log(`could not leave ${room}: ${err.message}`))
|
|
178
|
+
if (busy || changedTool) await this.report().catch(() => {})
|
|
179
|
+
return n
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The person's settings, as this machine's own "Work on its own" settings. */
|
|
183
|
+
applyConfig (c) {
|
|
184
|
+
this.config = c
|
|
185
|
+
saveSettings({ tool: c.tool, autoWork: 'auto', autoFiles: true, autoModel: c.model || undefined, autoCap: c.cap || undefined, autoPrompt: c.prompt || undefined })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------- sign-in --
|
|
189
|
+
|
|
190
|
+
async checkSignedIn () {
|
|
191
|
+
const tool = this.config && this.config.tool
|
|
192
|
+
const t = SIGN_IN[tool]
|
|
193
|
+
if (!t) return false
|
|
194
|
+
if (t.status.file) return !!keptToken(this.home, tool)
|
|
195
|
+
const exe = this.which(t.exe)
|
|
196
|
+
if (!exe) return false
|
|
197
|
+
const r = await this.status(exe, t.status.args)
|
|
198
|
+
return statusSaysSignedIn(tool, r.code, r.output)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Runs the tool's own login; what the person must see goes to the app through the API. */
|
|
202
|
+
async startSignIn () {
|
|
203
|
+
const tool = this.config && this.config.tool
|
|
204
|
+
const t = SIGN_IN[tool]
|
|
205
|
+
if (!t) return
|
|
206
|
+
if (this.signing) this.signing.kill()
|
|
207
|
+
const exe = this.which(t.exe)
|
|
208
|
+
if (!exe) { this.setStep({ step: 'failed', error: `${tool} isn't installed on this machine` }); return }
|
|
209
|
+
let out = ''
|
|
210
|
+
let shown = ''
|
|
211
|
+
const proc = this.login(exe, t.args, { env: t.env })
|
|
212
|
+
this.signing = proc
|
|
213
|
+
this.lastActivity = this.now()
|
|
214
|
+
this.setStep({ step: 'starting' })
|
|
215
|
+
const timer = setTimeout(() => proc.kill(), SIGN_IN_MS)
|
|
216
|
+
timer.unref?.()
|
|
217
|
+
proc.onData((d) => {
|
|
218
|
+
out = (out + d).slice(-64 * 1024)
|
|
219
|
+
const r = readSignIn(tool, out)
|
|
220
|
+
if (r.token) {
|
|
221
|
+
keepToken(this.home, tool, r.token)
|
|
222
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = r.token
|
|
223
|
+
}
|
|
224
|
+
const key = `${r.url}\n${r.code}`
|
|
225
|
+
if (r.url && key !== shown && this.signIn?.step !== 'waiting') {
|
|
226
|
+
shown = key
|
|
227
|
+
this.setStep({ step: r.code ? 'code' : 'link', url: r.url, code: r.code, needsCode: r.needsCode })
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
proc.exited.then(async (code) => {
|
|
231
|
+
clearTimeout(timer)
|
|
232
|
+
if (this.signing !== proc) return
|
|
233
|
+
this.signing = null
|
|
234
|
+
this.signedIn = await this.checkSignedIn()
|
|
235
|
+
this.lastActivity = this.now()
|
|
236
|
+
this.setStep(this.signedIn ? { step: 'done' } : { step: 'failed', error: `${tool}'s sign-in stopped (exit ${code}); start it again` })
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The code the person pasted, typed into the tool's login. */
|
|
241
|
+
giveCode (code) {
|
|
242
|
+
if (!this.signing) { this.log('a sign-in code came with no sign-in running'); return }
|
|
243
|
+
this.signing.write(`${code}\r`)
|
|
244
|
+
this.setStep({ ...(this.signIn || {}), step: 'waiting', needsCode: false })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
setStep (step) {
|
|
248
|
+
this.signIn = { url: '', code: '', needsCode: false, error: '', ...step }
|
|
249
|
+
this.report().catch(() => {})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ------------------------------------------------------------ sessions --
|
|
253
|
+
|
|
254
|
+
followedFile () { return path.join(this.dir, 'sessions.json') }
|
|
255
|
+
/** The sessions this machine follows: room -> link (kept on its own disk, never by the API). */
|
|
256
|
+
followed () { try { return JSON.parse(fs.readFileSync(this.followedFile(), 'utf8')) || {} } catch { return {} } }
|
|
257
|
+
saveFollowed (map) { writePrivateJson(this.followedFile(), map) }
|
|
258
|
+
|
|
259
|
+
async join (opts) {
|
|
260
|
+
if (this.joinImpl) return this.joinImpl(opts)
|
|
261
|
+
const { runSession } = await import('./runner.js')
|
|
262
|
+
return runSession(opts)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async autowork () {
|
|
266
|
+
if (this.autoworkImpl) return this.autoworkImpl
|
|
267
|
+
const { autowork } = await import('./autowork.js')
|
|
268
|
+
this.autoworkImpl = autowork()
|
|
269
|
+
return this.autoworkImpl
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Joins a session the person joined (again, after a restart), as their cloud AI. */
|
|
273
|
+
async follow (link) {
|
|
274
|
+
const invite = parseInvite(link, { allowRelay: () => true })
|
|
275
|
+
const room = invite.room
|
|
276
|
+
if (this.runs.has(room)) return
|
|
277
|
+
const { sessionPasses } = await import('./pass-source.js')
|
|
278
|
+
const auth = sessionPasses({ agent: AGENT_NAME })
|
|
279
|
+
const dir = path.join(this.home, 'projects', room)
|
|
280
|
+
const run = await this.join({
|
|
281
|
+
dir,
|
|
282
|
+
conn: { server: invite.relay || relayUrl(), room, secret: invite.secret },
|
|
283
|
+
name: auth.name,
|
|
284
|
+
tool: (this.config && this.config.tool) || 'Quilt cloud AI',
|
|
285
|
+
kind: 'agent',
|
|
286
|
+
passes: auth.passes,
|
|
287
|
+
identity: auth.identity,
|
|
288
|
+
agentFeed: false,
|
|
289
|
+
onLog: (line) => this.log(`[${room}] ${line}`),
|
|
290
|
+
onFatal: (err) => { this.log(`[${room}] stopped: ${err.message}`); this.runs.delete(room) }
|
|
291
|
+
})
|
|
292
|
+
this.runs.set(room, { run, link })
|
|
293
|
+
// What reaches it in the session counts as activity: the machine stays up while there is work.
|
|
294
|
+
if (run.session && run.session.on) {
|
|
295
|
+
run.session.on('inbox', () => { this.lastActivity = this.now() })
|
|
296
|
+
run.session.on('message', () => { this.lastActivity = this.now() })
|
|
297
|
+
}
|
|
298
|
+
this.saveFollowed({ ...this.followed(), [room]: link })
|
|
299
|
+
this.lastActivity = this.now()
|
|
300
|
+
this.log(`joined ${room}`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Leaves a session the person left for good, and frees its folder. */
|
|
304
|
+
async leave (room) {
|
|
305
|
+
if (!ROOM.test(String(room))) return
|
|
306
|
+
const r = this.runs.get(room)
|
|
307
|
+
this.runs.delete(room)
|
|
308
|
+
if (r) await r.run.stop().catch(() => {})
|
|
309
|
+
const kept = this.followed()
|
|
310
|
+
delete kept[room]
|
|
311
|
+
this.saveFollowed(kept)
|
|
312
|
+
fs.rmSync(path.join(this.home, 'projects', room), { recursive: true, force: true })
|
|
313
|
+
this.log(`left ${room}`)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --------------------------------------------------- reports and idle --
|
|
317
|
+
|
|
318
|
+
async report () {
|
|
319
|
+
const work = await this.autowork()
|
|
320
|
+
const log = work.log ? work.log() : []
|
|
321
|
+
const today = log.filter((e) => new Date(e.ts).toISOString().slice(0, 10) === new Date(this.now()).toISOString().slice(0, 10))
|
|
322
|
+
const sessions = [...this.runs.entries()].map(([room, { run }]) => ({ room, name: (run.session && run.session.sessionName) || '' }))
|
|
323
|
+
return this.call('POST', '/v1/ai-vm/report', {
|
|
324
|
+
signedIn: this.signedIn,
|
|
325
|
+
...(this.signIn ? { signIn: this.signIn } : {}),
|
|
326
|
+
sessions,
|
|
327
|
+
usageToday: { tokens: tokensOn(log, this.now()), runs: today.length }
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Whether anything runs or waits here now. */
|
|
332
|
+
async busy () {
|
|
333
|
+
if (this.signing) return true
|
|
334
|
+
const work = await this.autowork()
|
|
335
|
+
return !!(work.running || (work.queue && work.queue.length))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Nothing to do for a while: ask to be suspended. The machine resumes here when woken. */
|
|
339
|
+
async idleCheck () {
|
|
340
|
+
if (this.stopped) return false
|
|
341
|
+
if (await this.busy()) { this.lastActivity = this.now(); return false }
|
|
342
|
+
if (this.now() - this.lastActivity < this.idleMs) return false
|
|
343
|
+
this.lastActivity = this.now()
|
|
344
|
+
try {
|
|
345
|
+
await this.call('POST', '/v1/ai-vm/idle', {})
|
|
346
|
+
this.log('idle: suspending')
|
|
347
|
+
return true
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (err.status !== 409) this.log(`could not suspend: ${err.message}`)
|
|
350
|
+
return false
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
serveHealth () {
|
|
355
|
+
this.health = http.createServer((req, res) => {
|
|
356
|
+
if (req.url !== '/healthz') { res.writeHead(404); return res.end() }
|
|
357
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
358
|
+
res.end(JSON.stringify({ ok: true, tool: this.config?.tool || '', signedIn: this.signedIn, sessions: [...this.runs.keys()] }))
|
|
359
|
+
})
|
|
360
|
+
return new Promise((resolve) => this.health.listen(this.port, '0.0.0.0', resolve))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Finds the first of `names` on PATH. */
|
|
365
|
+
export function onPath (names, { env = process.env } = {}) {
|
|
366
|
+
for (const n of names) for (const d of String(env.PATH || '').split(path.delimiter)) if (d && fs.existsSync(path.join(d, n))) return path.join(d, n)
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** `quilt ai-vm`, from the machine's environment. */
|
|
371
|
+
export async function runAiVm (env = process.env) {
|
|
372
|
+
if (!env.QUILT_API) throw new Error('QUILT_API is not set: this command runs on a cloud AI machine Quilt started.')
|
|
373
|
+
const vm = new AiVm({ api: env.QUILT_API, joinLink: env.QUILT_AI_VM_JOIN || '', home: env.HOME, which: (names) => onPath(names), port: Number(env.PORT || 8080) })
|
|
374
|
+
const shutdown = async () => { await vm.stop(); process.exit(0) }
|
|
375
|
+
process.on('SIGINT', shutdown)
|
|
376
|
+
process.on('SIGTERM', shutdown)
|
|
377
|
+
await vm.start()
|
|
378
|
+
console.log('quilt ai-vm: running')
|
|
379
|
+
return vm
|
|
380
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Access types and grants for the API's routes: finding a type (a built-in, or one of
|
|
2
|
+
// the owner's own) and what a grant comes to.
|
|
3
|
+
import { UUID } from './http.js'
|
|
4
|
+
import { builtinType, effectiveAccess, FALLBACK_TYPE } from '../session-access.js'
|
|
5
|
+
import { workspaceAccess } from './workspace-access.js'
|
|
6
|
+
import { orgGrantsFor } from './org-access.js'
|
|
7
|
+
|
|
8
|
+
// The owner's access, as a pass carries it. The relay keeps its own record of who owns a
|
|
9
|
+
// room; this only says the API agrees.
|
|
10
|
+
export const OWNER_ACCESS = Object.freeze({ owner: true, files: 'edit', folders: Object.freeze([]), foldersExcept: Object.freeze([]), talk: true })
|
|
11
|
+
|
|
12
|
+
/** A built-in, or one of `ownerAccount`'s own types; null otherwise (someone else's, or none). */
|
|
13
|
+
export async function ownType (store, ownerAccount, typeId) {
|
|
14
|
+
const id = String(typeId || '')
|
|
15
|
+
const b = builtinType(id)
|
|
16
|
+
if (b) return b
|
|
17
|
+
if (!UUID.test(id)) return null
|
|
18
|
+
const t = await store.accessTypeById(id)
|
|
19
|
+
return t && t.ownerAccount === ownerAccount ? t : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A grant's type: View only when its own is gone (deleted while this was read). */
|
|
23
|
+
export async function typeOfGrant (store, grant) {
|
|
24
|
+
return builtinType(grant.typeId) || (UUID.test(grant.typeId) && await store.accessTypeById(grant.typeId)) || builtinType(FALLBACK_TYPE)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A grant as the owner sees it: its type's name and what it comes to. */
|
|
28
|
+
export async function grantView (store, grant) {
|
|
29
|
+
const type = await typeOfGrant(store, grant)
|
|
30
|
+
return { account: grant.account, typeId: type.id, typeName: type.name, tighten: grant.tighten || {}, access: effectiveAccess(type, grant.tighten), updatedAt: grant.updatedAt }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What `account` may do in `room`, for its pass: the owner's access, its grant's, or null
|
|
35
|
+
* (no grant: the owner lets them in, or not). `email` is a person's confirmed sign-in
|
|
36
|
+
* address: an open invite to it becomes their grant now, and the invite is used.
|
|
37
|
+
*/
|
|
38
|
+
export async function roomAccess (store, room, account, email = '') {
|
|
39
|
+
return (await roomAccessFrom(store, room, account, email)).access
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* roomAccess, and where it comes from, for the session's member list: { kind: 'owner' },
|
|
44
|
+
* { kind: 'grant' } (let in here), { kind: 'workspace', via, workspace } or { kind: 'org',
|
|
45
|
+
* workspace, org }. { access: null, from: null } when there's none.
|
|
46
|
+
*/
|
|
47
|
+
export async function roomAccessFrom (store, room, account, email = '') {
|
|
48
|
+
const none = { access: null, from: null }
|
|
49
|
+
const session = await store.sessionByRoom(room)
|
|
50
|
+
if (!session) return none
|
|
51
|
+
if (session.ownerAccount === account) return { access: OWNER_ACCESS, from: { kind: 'owner' } }
|
|
52
|
+
if (email) await store.claimEmailInvites(room, email.toLowerCase(), account)
|
|
53
|
+
const grant = await store.grantFor(room, account)
|
|
54
|
+
if (grant) {
|
|
55
|
+
await store.useAccountInvites(room, account)
|
|
56
|
+
return { access: effectiveAccess(await typeOfGrant(store, grant), grant.tighten), from: { kind: 'grant' } }
|
|
57
|
+
}
|
|
58
|
+
// No grant of its own: a session inside a workspace admits the workspace's people, but
|
|
59
|
+
// only when its owner (as the relay reports it) put it there. Anyone else's link, or one
|
|
60
|
+
// made before the relay has named an owner, lets nobody in.
|
|
61
|
+
if (!session.workspaceId || !session.ownerAccount || session.ownerAccount !== session.workspaceLinkedBy) return none
|
|
62
|
+
// A workspace's agents are invited to its sessions (sent the link), never let in by it:
|
|
63
|
+
// like anyone with a link they wait, and the session owner lets them in (a grant, above).
|
|
64
|
+
if (account.startsWith('agent:')) return none
|
|
65
|
+
const ws = await store.workspaceById(session.workspaceId)
|
|
66
|
+
if (!ws) return none
|
|
67
|
+
const wa = await workspaceAccess(store, ws, account, { orgGrants: ws.orgId ? await orgGrantsFor(store, ws.orgId, account) : null })
|
|
68
|
+
if (!wa) return none
|
|
69
|
+
const access = effectiveAccess(builtinType(wa.access === 'edit' ? 'builtin:edit' : 'builtin:view'), {})
|
|
70
|
+
if (wa.via === 'org') {
|
|
71
|
+
const org = ws.orgId ? await store.orgById(ws.orgId) : null
|
|
72
|
+
return { access, from: { kind: 'org', workspace: ws.name || '', org: org?.name || '' } }
|
|
73
|
+
}
|
|
74
|
+
return { access, from: { kind: 'workspace', via: wa.via, workspace: ws.name || '' } }
|
|
75
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Time spent in sessions, from the visits the relay reports (see routes/relay.js).
|
|
2
|
+
// Pure: the API's routes hand it rows from the store and the time now. Times are
|
|
3
|
+
// epoch ms; an interval is [start, end) and a list of them is kept sorted and merged.
|
|
4
|
+
|
|
5
|
+
export const DAY_MS = 24 * 60 * 60 * 1000
|
|
6
|
+
export const MAX_SESSIONS = 100
|
|
7
|
+
export const MAX_PEOPLE_TOP = 3
|
|
8
|
+
export const MAX_COLLABORATORS = 30
|
|
9
|
+
export const MAX_VISITS = 20
|
|
10
|
+
|
|
11
|
+
/** Sorted, overlapping and touching intervals joined, empty ones dropped. */
|
|
12
|
+
export function merge (intervals) {
|
|
13
|
+
const sorted = intervals.filter(([s, e]) => e > s).sort((a, b) => a[0] - b[0])
|
|
14
|
+
const out = []
|
|
15
|
+
for (const [s, e] of sorted) {
|
|
16
|
+
const last = out[out.length - 1]
|
|
17
|
+
if (last && s <= last[1]) last[1] = Math.max(last[1], e)
|
|
18
|
+
else out.push([s, e])
|
|
19
|
+
}
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Where two merged lists overlap, as a merged list. */
|
|
24
|
+
export function intersect (a, b) {
|
|
25
|
+
const out = []
|
|
26
|
+
let i = 0
|
|
27
|
+
let j = 0
|
|
28
|
+
while (i < a.length && j < b.length) {
|
|
29
|
+
const s = Math.max(a[i][0], b[j][0])
|
|
30
|
+
const e = Math.min(a[i][1], b[j][1])
|
|
31
|
+
if (e > s) out.push([s, e])
|
|
32
|
+
if (a[i][1] < b[j][1]) i++
|
|
33
|
+
else j++
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A merged list cut to [from, to). */
|
|
39
|
+
export function clip (list, from, to) {
|
|
40
|
+
return list.map(([s, e]) => [Math.max(s, from), Math.min(e, to)]).filter(([s, e]) => e > s)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const total = (list) => list.reduce((n, [s, e]) => n + (e - s), 0)
|
|
44
|
+
|
|
45
|
+
/** A visit as an interval: an open visit runs until now. */
|
|
46
|
+
const span = (v, now) => [v.startedAt, v.endedAt == null ? now : Math.min(v.endedAt, now)]
|
|
47
|
+
|
|
48
|
+
const formatters = new Map()
|
|
49
|
+
function partsIn (t, tz) {
|
|
50
|
+
let f = formatters.get(tz)
|
|
51
|
+
if (!f) {
|
|
52
|
+
f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', weekday: 'short', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })
|
|
53
|
+
formatters.set(tz, f)
|
|
54
|
+
}
|
|
55
|
+
const p = Object.fromEntries(f.formatToParts(new Date(t)).map((x) => [x.type, x.value]))
|
|
56
|
+
return { year: +p.year, month: +p.month, day: +p.day, hour: +p.hour, minute: +p.minute, second: +p.second, weekday: p.weekday }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether `tz` is an IANA time zone this runtime knows ("Europe/Paris", "UTC"). */
|
|
60
|
+
export function isTimeZone (tz) {
|
|
61
|
+
if (typeof tz !== 'string' || !tz || tz.length > 64) return false
|
|
62
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(0); return true } catch { return false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How far `tz` is ahead of UTC at `t`, in ms. */
|
|
66
|
+
export function zoneOffset (t, tz) {
|
|
67
|
+
const p = partsIn(t, tz)
|
|
68
|
+
return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(t / 1000) * 1000
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 00:00 on a calendar day in `tz` (month 1-12; days past the month's end roll over). */
|
|
72
|
+
export function midnightIn (year, month, day, tz) {
|
|
73
|
+
const guess = Date.UTC(year, month - 1, day)
|
|
74
|
+
const first = guess - zoneOffset(guess, tz)
|
|
75
|
+
return guess - zoneOffset(first, tz)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|
79
|
+
|
|
80
|
+
/** Monday 00:00 of the week `now` falls in, in `tz`. */
|
|
81
|
+
export function weekStart (now, tz) {
|
|
82
|
+
const p = partsIn(now, tz)
|
|
83
|
+
return midnightIn(p.year, p.month, p.day - WEEKDAYS.indexOf(p.weekday), tz)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The 1st of the month `now` falls in, 00:00 in `tz`. */
|
|
87
|
+
export function monthStart (now, tz) {
|
|
88
|
+
const p = partsIn(now, tz)
|
|
89
|
+
return midnightIn(p.year, p.month, 1, tz)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The newest name an account went by in these rows. */
|
|
93
|
+
function latest (rows) {
|
|
94
|
+
return rows.reduce((a, b) => (b.startedAt > a.startedAt ? b : a))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The dashboard: for `me` ('person:<id>'), each session in `sessions` I was in, my
|
|
99
|
+
* time there and the people and agents whose visits overlapped mine; plus my time
|
|
100
|
+
* collaborating this week and who I worked with most this month (both in `tz`).
|
|
101
|
+
* `visits` are every visit in those sessions. Nobody I never overlapped with appears.
|
|
102
|
+
*/
|
|
103
|
+
export function summarize ({ me, sessions, visits, now, tz = 'UTC' }) {
|
|
104
|
+
const byRoom = new Map()
|
|
105
|
+
for (const v of visits) {
|
|
106
|
+
if (!byRoom.has(v.room)) byRoom.set(v.room, [])
|
|
107
|
+
byRoom.get(v.room).push(v)
|
|
108
|
+
}
|
|
109
|
+
const collab = [] // time I overlapped anyone, across every session
|
|
110
|
+
const together = new Map() // account -> { name, kind, spans } across every session
|
|
111
|
+
const rows = []
|
|
112
|
+
for (const s of sessions) {
|
|
113
|
+
const inRoom = byRoom.get(s.room) || []
|
|
114
|
+
const mineRows = inRoom.filter((v) => v.account === me)
|
|
115
|
+
if (!mineRows.length) continue
|
|
116
|
+
const mine = merge(mineRows.map((v) => span(v, now)))
|
|
117
|
+
const others = new Map()
|
|
118
|
+
for (const v of inRoom) {
|
|
119
|
+
if (v.account === me) continue
|
|
120
|
+
if (!others.has(v.account)) others.set(v.account, [])
|
|
121
|
+
others.get(v.account).push(v)
|
|
122
|
+
}
|
|
123
|
+
collab.push(...intersect(mine, merge(inRoom.filter((v) => v.account !== me).map((v) => span(v, now)))))
|
|
124
|
+
const people = []
|
|
125
|
+
for (const [account, theirs] of others) {
|
|
126
|
+
const both = intersect(mine, merge(theirs.map((v) => span(v, now))))
|
|
127
|
+
const ms = total(both)
|
|
128
|
+
if (ms <= 0) continue
|
|
129
|
+
const last = latest(theirs)
|
|
130
|
+
people.push({ account, name: last.accountName, kind: last.kind, togetherMs: ms, lastTogetherAt: both[both.length - 1][1] })
|
|
131
|
+
const t = together.get(account) || { name: last.accountName, kind: last.kind, at: 0, spans: [] }
|
|
132
|
+
if (last.startedAt >= t.at) Object.assign(t, { name: last.accountName, kind: last.kind, at: last.startedAt })
|
|
133
|
+
t.spans.push(...both)
|
|
134
|
+
together.set(account, t)
|
|
135
|
+
}
|
|
136
|
+
people.sort((a, b) => b.togetherMs - a.togetherMs || a.name.localeCompare(b.name))
|
|
137
|
+
const ownerRows = s.ownerAccount ? inRoom.filter((v) => v.account === s.ownerAccount) : []
|
|
138
|
+
const open = inRoom.some((v) => v.endedAt == null)
|
|
139
|
+
rows.push({
|
|
140
|
+
room: s.room,
|
|
141
|
+
name: s.name || '',
|
|
142
|
+
owner: { name: ownerRows.length ? latest(ownerRows).accountName : '' },
|
|
143
|
+
mine: !!s.ownerAccount && s.ownerAccount === me,
|
|
144
|
+
createdAt: s.createdAt,
|
|
145
|
+
lastActiveAt: open ? now : s.lastActiveAt,
|
|
146
|
+
myTotalMs: total(mine),
|
|
147
|
+
people
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
rows.sort((a, b) => b.lastActiveAt - a.lastActiveAt || a.room.localeCompare(b.room))
|
|
151
|
+
const week = weekStart(now, tz)
|
|
152
|
+
const month = monthStart(now, tz)
|
|
153
|
+
const topCollaborators = [...together].map(([account, t]) => ({ account, name: t.name, kind: t.kind, ms: total(clip(merge(t.spans), month, now)) }))
|
|
154
|
+
.filter((c) => c.ms > 0)
|
|
155
|
+
.sort((a, b) => b.ms - a.ms || a.name.localeCompare(b.name))
|
|
156
|
+
.slice(0, MAX_PEOPLE_TOP)
|
|
157
|
+
return {
|
|
158
|
+
totals: { collaboratingThisWeek: total(clip(merge(collab), week, now)), topCollaborators },
|
|
159
|
+
sessions: rows.slice(0, MAX_SESSIONS)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Everyone I've overlapped with in these sessions, most recent first (for invites). */
|
|
164
|
+
export function collaborators (sessions) {
|
|
165
|
+
const seen = new Map()
|
|
166
|
+
for (const s of sessions) {
|
|
167
|
+
for (const p of s.people) {
|
|
168
|
+
const was = seen.get(p.account)
|
|
169
|
+
if (!was || p.lastTogetherAt > was.lastTogetherAt) seen.set(p.account, { account: p.account, name: p.name, kind: p.kind, lastTogetherAt: p.lastTogetherAt })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return [...seen.values()].sort((a, b) => b.lastTogetherAt - a.lastTogetherAt).slice(0, MAX_COLLABORATORS)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** My last visits to one session, newest first. */
|
|
176
|
+
export function myVisits ({ me, visits }) {
|
|
177
|
+
return visits.filter((v) => v.account === me)
|
|
178
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
179
|
+
.slice(0, MAX_VISITS)
|
|
180
|
+
.map((v) => ({ startedAt: v.startedAt, endedAt: v.endedAt ?? null }))
|
|
181
|
+
}
|