@davait/aipictury 1.19.5 → 1.20.1
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/ext/about.js +2 -2
- package/ext/accountUi.js +83 -21
- package/ext/agent.js +84 -11
- package/ext/attachments.js +55 -0
- package/ext/cloud.js +165 -32
- package/ext/dialogs.js +9 -5
- package/ext/extension.js +281 -42
- package/ext/feedback.js +78 -6
- package/ext/firestore.js +37 -6
- package/ext/gpuRun.js +31 -7
- package/ext/keeping.js +171 -0
- package/ext/mediagen.js +186 -20
- package/ext/memory.js +17 -7
- package/ext/package.json +1 -1
- package/ext/plans.js +4 -1
- package/ext/remote.js +72 -12
- package/ext/voice.js +62 -11
- package/ext/workbench.js +9 -2
- package/main.js +96 -27
- package/package.json +1 -1
- package/vscode-shim.js +3 -0
package/ext/feedback.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
// A problem report: what AIPictury needs to understand a problem — versions, settings, the
|
|
3
3
|
// last lines of its log, your description — with keys, tokens and passwords taken out, saved
|
|
4
4
|
// on this machine (~/.aipictury/feedback/). Sent to your AIPictury server only when you say so
|
|
5
|
-
// (/bug, /feedback, or AIPictury: Report a Problem).
|
|
5
|
+
// (/bug, /feedback, or AIPictury: Report a Problem). The folder is also the outbox: a report
|
|
6
|
+
// you chose to send that did not reach the server is sent again by itself when the extension
|
|
7
|
+
// starts and now and then after (sendWaiting), under the same `ref`, so the server keeps it
|
|
8
|
+
// once however many tries it takes. It used to be one 10-second try, then never (1.20.0).
|
|
9
|
+
const crypto = require('crypto')
|
|
6
10
|
const fs = require('fs')
|
|
7
11
|
const os = require('os')
|
|
8
12
|
const path = require('path')
|
|
@@ -38,18 +42,86 @@ function redactDeep(value) {
|
|
|
38
42
|
|
|
39
43
|
const dir = () => path.join(home(), 'feedback')
|
|
40
44
|
|
|
41
|
-
/** Writes a report: kind "bug" or "feedback".
|
|
42
|
-
|
|
45
|
+
/** Writes a report: kind "bug" or "feedback". `send`: it is to reach the server (the outbox).
|
|
46
|
+
* `body` is what is sent: with its `ref`, without this machine's note of how sending goes. */
|
|
47
|
+
function save({ kind = 'report', description = '', versions = {}, settings = {}, log = [], extra = {}, send = false }) {
|
|
43
48
|
fs.mkdirSync(dir(), { recursive: true })
|
|
44
49
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
45
|
-
const
|
|
50
|
+
const ref = crypto.randomUUID() // the same at every try: the server keeps the report once
|
|
51
|
+
// Named by its ref too: two reports in the same millisecond were one file.
|
|
52
|
+
const file = path.join(dir(), `${stamp}-${kind}-${ref.slice(0, 8)}.json`)
|
|
46
53
|
const body = redactDeep({
|
|
47
54
|
kind, created: new Date().toISOString(), description,
|
|
48
55
|
versions: { ...versions, platform: `${os.platform()} ${os.release()}`, node: process.version },
|
|
49
56
|
settings, log, ...extra,
|
|
50
57
|
})
|
|
51
|
-
|
|
58
|
+
body.ref = ref
|
|
59
|
+
write(file, { ...body, delivery: { send: !!send, sent: null, tries: 0 } })
|
|
52
60
|
return { file, body }
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
|
|
63
|
+
function write(file, report) { fs.writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }) }
|
|
64
|
+
|
|
65
|
+
function read(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null } }
|
|
66
|
+
|
|
67
|
+
/** The server took it (its id): not sent again. */
|
|
68
|
+
function markSent(file, id) {
|
|
69
|
+
const report = read(file)
|
|
70
|
+
if (report) write(file, { ...report, delivery: { ...(report.delivery || {}), sent: String(id || 'sent') } })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Reports being sent by this app now, by ref: the first send (the dialog's) and the outbox's retry
|
|
74
|
+
// 45 s after start could post the same report at once, and the server kept it twice (1.20.0).
|
|
75
|
+
const sending = new Map()
|
|
76
|
+
|
|
77
|
+
/** One report sent, and marked sent once the server took it: resolves to its id there. While it
|
|
78
|
+
* is on its way, asking again (the outbox) gets the same try, never a second post. */
|
|
79
|
+
function sendOne(server, file, body, send) {
|
|
80
|
+
if (!sending.has(body.ref)) {
|
|
81
|
+
sending.set(body.ref, (async () => {
|
|
82
|
+
const id = await send(server, body)
|
|
83
|
+
markSent(file, id)
|
|
84
|
+
return id
|
|
85
|
+
})().finally(() => sending.delete(body.ref)))
|
|
86
|
+
}
|
|
87
|
+
return sending.get(body.ref)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The reports to send that have not reached the server: [{ file, body }], oldest first. */
|
|
91
|
+
function unsent() {
|
|
92
|
+
let names
|
|
93
|
+
try { names = fs.readdirSync(dir()).filter((n) => n.endsWith('.json')).sort() } catch { return [] }
|
|
94
|
+
const out = []
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
const report = read(path.join(dir(), name))
|
|
97
|
+
if (!report?.delivery?.send || report.delivery.sent || report.delivery.refused || !report.ref) continue
|
|
98
|
+
const body = { ...report }
|
|
99
|
+
delete body.delivery
|
|
100
|
+
out.push({ file: path.join(dir(), name), body })
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Sends what is waiting, with `send(server, body)` (dialogs.sendReport); stops at the first that
|
|
106
|
+
* cannot reach the server — it is not there yet — and says how many went. One the server
|
|
107
|
+
* refuses (a 4xx it will always answer) is put aside, not tried for ever. */
|
|
108
|
+
async function sendWaiting(server, send) {
|
|
109
|
+
if (!server) return 0
|
|
110
|
+
let sent = 0
|
|
111
|
+
for (const { file, body } of unsent()) {
|
|
112
|
+
if (sending.has(body.ref)) continue // on its way already: counted by its sender
|
|
113
|
+
try {
|
|
114
|
+
await sendOne(server, file, body, send)
|
|
115
|
+
sent += 1
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const report = read(file)
|
|
118
|
+
const refused = error.status >= 400 && error.status < 500 && ![401, 403, 408, 429].includes(error.status)
|
|
119
|
+
if (report) write(file, { ...report, delivery: { ...report.delivery, tries: (report.delivery.tries || 0) + 1,
|
|
120
|
+
error: String(error.message).slice(0, 300), ...(refused ? { refused: true } : {}) } })
|
|
121
|
+
if (!refused) break
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return sent
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { redact, redactDeep, save, markSent, sendOne, unsent, sendWaiting, dir }
|
package/ext/firestore.js
CHANGED
|
@@ -47,6 +47,12 @@ function decodeFields(fields) {
|
|
|
47
47
|
return out
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** A field path as Firestore reads it: a segment that is not a plain name ("qwen3.5:27b", a tool
|
|
51
|
+
* name with a dash) is quoted in backticks. */
|
|
52
|
+
function fieldPath(...segments) {
|
|
53
|
+
return segments.map((seg) => (/^[A-Za-z_][A-Za-z_0-9]*$/.test(seg) ? seg : `\`${String(seg).replace(/[\\`]/g, (c) => `\\${c}`)}\``)).join('.')
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
class FirestoreError extends Error {
|
|
51
57
|
constructor(status, message, code) { super(message); this.status = status; this.code = code }
|
|
52
58
|
}
|
|
@@ -105,7 +111,8 @@ class Firestore {
|
|
|
105
111
|
try { await this.request('DELETE', this.url(path)) } catch (error) { if (error.status !== 404) throw error }
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
/** Every document of a collection: [{ id, data }].
|
|
114
|
+
/** Every document of a collection: [{ id, data }]. Only documents that exist: Firestore's
|
|
115
|
+
* showMissing is for admin credentials, and refuses a signed-in person outright. */
|
|
109
116
|
async list(collectionPath, { pageSize = 300 } = {}) {
|
|
110
117
|
const out = []
|
|
111
118
|
let token = ''
|
|
@@ -117,15 +124,39 @@ class Firestore {
|
|
|
117
124
|
return out
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
/** Several writes,
|
|
127
|
+
/** Several writes, in commits of up to 400. Each commit is all or nothing, the whole is not: a
|
|
128
|
+
* cut between commits leaves the first ones done, so a caller that must survive that writes
|
|
129
|
+
* its marker first (cloud.deleteEverything). Each: { set: [path, data] }, { delete: path }, or
|
|
130
|
+
* { change: [path, change] } (see change()). */
|
|
121
131
|
async batch(writes) {
|
|
122
132
|
for (let i = 0; i < writes.length; i += BATCH_MAX) {
|
|
123
|
-
const part = writes.slice(i, i + BATCH_MAX).map((w) => (w.delete
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
const part = writes.slice(i, i + BATCH_MAX).map((w) => (w.delete ? { delete: `${this.root}/${w.delete}` }
|
|
134
|
+
: w.change ? this.changeWrite(w.change[0], w.change[1])
|
|
135
|
+
: { update: { name: `${this.root}/${w.set[0]}`, fields: encodeFields(w.set[1]) } }))
|
|
126
136
|
await this.request('POST', `${this.base}/${this.root}:commit`, { writes: part })
|
|
127
137
|
}
|
|
128
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Changes a document on the server, in one step, whatever another device writes meanwhile: the
|
|
142
|
+
* fields of `set` replaced, numbers in `increment` added to (a field path → by how much), and
|
|
143
|
+
* the values in `append` / `remove` added to an array (when not there) or taken out of it.
|
|
144
|
+
* The document is made when it is missing. Read-modify-write lost one of two rules saved at
|
|
145
|
+
* once, and one of two machines' turns in the day's usage (1.20.0).
|
|
146
|
+
*/
|
|
147
|
+
async change(path, spec) {
|
|
148
|
+
await this.request('POST', `${this.base}/${this.root}:commit`, { writes: [this.changeWrite(path, spec)] })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
changeWrite(path, { set = {}, increment = {}, append = {}, remove = {} } = {}) {
|
|
152
|
+
const transforms = [
|
|
153
|
+
...Object.entries(increment).map(([field, by]) => ({ fieldPath: field, increment: encode(by) })),
|
|
154
|
+
...Object.entries(append).map(([field, values]) => ({ fieldPath: field, appendMissingElements: { values: values.map(encode) } })),
|
|
155
|
+
...Object.entries(remove).map(([field, values]) => ({ fieldPath: field, removeAllFromArray: { values: values.map(encode) } })),
|
|
156
|
+
]
|
|
157
|
+
return { update: { name: `${this.root}/${path}`, fields: encodeFields(set) },
|
|
158
|
+
updateMask: { fieldPaths: Object.keys(set).map((k) => fieldPath(k)) }, updateTransforms: transforms }
|
|
159
|
+
}
|
|
129
160
|
}
|
|
130
161
|
|
|
131
|
-
module.exports = { Firestore, FirestoreError, encode, decode, encodeFields, decodeFields }
|
|
162
|
+
module.exports = { Firestore, FirestoreError, encode, decode, encodeFields, decodeFields, fieldPath }
|
package/ext/gpuRun.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
// The GPU, held for one run at a time (the server's gpu.py): an agent run — from the prompt to
|
|
3
3
|
// its last step, helpers and tools included — names itself on every call it makes to the GPU
|
|
4
4
|
// server (X-AIPictury-Client, -Run, -Label: account.js serverFetch adds them), takes the GPU when
|
|
5
|
-
// it starts, keeps it alive while
|
|
6
|
-
//
|
|
5
|
+
// it starts, keeps it alive while a GPU call of its own is under way (a model turn, streamed for
|
|
6
|
+
// minutes: using()), and lets go when it ends. Between its calls — a test suite, a build, any
|
|
7
|
+
// local step — nothing keeps it: the server lets it go 90 s after the run's last call, so another
|
|
8
|
+
// app is not kept off an idle GPU for as long as the step takes, and the run's next model call
|
|
9
|
+
// takes it again, or waits for it as for a render (agent.js, 423). It used to beat for the whole
|
|
10
|
+
// run, local steps included (1.20.0). Another app holding the GPU: the run does not start, and
|
|
11
|
+
// says who has it.
|
|
7
12
|
const crypto = require('crypto')
|
|
8
13
|
const { AsyncLocalStorage } = require('async_hooks')
|
|
9
14
|
|
|
@@ -77,19 +82,38 @@ async function holding({ server, client, label = '' }, fn) {
|
|
|
77
82
|
// No answer about holds (an older server, a moment's network trouble): the work goes on —
|
|
78
83
|
// its calls still carry the run, so the server holds the GPU for it if it can.
|
|
79
84
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}, BEAT) : null
|
|
85
|
+
run.held = held
|
|
86
|
+
run.hold = h
|
|
83
87
|
try {
|
|
84
88
|
return await fn()
|
|
85
89
|
} finally {
|
|
86
90
|
run.ended = true
|
|
87
|
-
if (beat) clearInterval(beat)
|
|
91
|
+
if (run.beat) clearInterval(run.beat)
|
|
92
|
+
run.beat = null
|
|
88
93
|
if (held) await fetch(`${server}/api/gpu/hold`, { method: 'DELETE', headers: h, signal: AbortSignal.timeout(10_000) }).catch(() => {})
|
|
89
94
|
}
|
|
90
95
|
})
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
/** A GPU call of the run in hand is under way: its hold is kept alive (every BEAT) until the
|
|
99
|
+
* function this returns is called, and while no such call is, it is not. Outside a run, or for
|
|
100
|
+
* a server without holds: nothing to keep. */
|
|
101
|
+
function using() {
|
|
102
|
+
const r = store.getStore()
|
|
103
|
+
if (!r || r.ended || !r.held) return () => {}
|
|
104
|
+
r.busy = (r.busy || 0) + 1
|
|
105
|
+
r.beat ||= setInterval(() => {
|
|
106
|
+
fetch(`${r.server}/api/gpu/hold`, { method: 'POST', headers: r.hold, signal: AbortSignal.timeout(10_000) }).catch(() => {})
|
|
107
|
+
}, BEAT)
|
|
108
|
+
let over = false
|
|
109
|
+
return () => {
|
|
110
|
+
if (over) return
|
|
111
|
+
over = true
|
|
112
|
+
r.busy -= 1
|
|
113
|
+
if (r.busy <= 0 && r.beat) { clearInterval(r.beat); r.beat = null }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
/** Who holds the GPU now: { lanes: [{ hold }] } — null when the server cannot say. */
|
|
94
118
|
async function state(server) {
|
|
95
119
|
try {
|
|
@@ -106,4 +130,4 @@ function busyFor(gpu, mine = null) {
|
|
|
106
130
|
return lanes[0].hold
|
|
107
131
|
}
|
|
108
132
|
|
|
109
|
-
module.exports = { holding, headers, setApp, nameCalls, state, busyFor, GpuBusyError, store }
|
|
133
|
+
module.exports = { holding, using, headers, setApp, nameCalls, state, busyFor, GpuBusyError, store, BEAT }
|
package/ext/keeping.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// The agent's renders kept with the account (cloud.keepRender: compressed on the GPU server, put in
|
|
3
|
+
// Storage, renders/{jobId} last), as an outbox on disk that the extension and the CLI share: a render
|
|
4
|
+
// is noted when it is done and crossed off only once it is kept, or once there is nothing to keep.
|
|
5
|
+
// The chain used to run once, from the render's own callback: a reload during the compression,
|
|
6
|
+
// Ctrl+C twice in the CLI or one failed upload lost it for good — with the files already uploaded
|
|
7
|
+
// left in Storage with no record — and the card said "Compressing" for ever (1.20.0). Whatever is
|
|
8
|
+
// left is now taken up again at the next start, at sign-in and every few minutes, by either app.
|
|
9
|
+
// A server that keeps renders by itself (keeper.py, GET /api/health `keeps`) needs no app for it:
|
|
10
|
+
// cloud.keepRender then only follows it, so the outbox serves the card, and older servers.
|
|
11
|
+
//
|
|
12
|
+
// Every VS Code window and the CLI drain the same file, so each render is taken by one of them at a
|
|
13
|
+
// time: its entry gets a lease (who, until when) before keepRender starts, renewed while it runs,
|
|
14
|
+
// and the others leave it alone while it holds — or until the app holding it is gone. Each change
|
|
15
|
+
// is made under a lock file. Without them a second window's sign-in compressed the same clip again
|
|
16
|
+
// on the GPU while the first was at it, and two renders finishing at once could lose one (1.20.0).
|
|
17
|
+
const fs = require('fs')
|
|
18
|
+
const os = require('os')
|
|
19
|
+
const path = require('path')
|
|
20
|
+
const crypto = require('crypto')
|
|
21
|
+
const { home } = require('./sessions')
|
|
22
|
+
|
|
23
|
+
const RETRY = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000] // after each failure, then every 2 h
|
|
24
|
+
const GIVE_UP = 7 * 24 * 3600_000 // not kept within a week: dropped
|
|
25
|
+
const LEASE = 2 * 60_000 // a render taken by an app is its own this long, renewed while it works
|
|
26
|
+
const RENEW = 30_000
|
|
27
|
+
const LOCK_STALE = 10_000 // a lock left by an app killed mid-write is broken after this
|
|
28
|
+
const LOCK_WAIT = 5_000
|
|
29
|
+
|
|
30
|
+
// This app, as a lease names it: the machine and process (to tell a dead holder from a busy one)
|
|
31
|
+
// and a token of its own (to know its own leases again).
|
|
32
|
+
const ME = { host: os.hostname(), pid: process.pid, token: crypto.randomBytes(6).toString('hex') }
|
|
33
|
+
|
|
34
|
+
const file = () => path.join(home(), 'keeping.json')
|
|
35
|
+
|
|
36
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
37
|
+
|
|
38
|
+
/** `fn` run under the outbox's lock: one app at a time reads, changes and writes it. The lock is
|
|
39
|
+
* held for a read and a write, never across keepRender. */
|
|
40
|
+
function locked(fn) {
|
|
41
|
+
const lock = `${file()}.lock`
|
|
42
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true })
|
|
43
|
+
const end = Date.now() + LOCK_WAIT
|
|
44
|
+
let fd = null
|
|
45
|
+
while (fd == null) {
|
|
46
|
+
try {
|
|
47
|
+
fd = fs.openSync(lock, 'wx', 0o600)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code !== 'EEXIST') throw error
|
|
50
|
+
let since
|
|
51
|
+
try { since = fs.statSync(lock).mtimeMs } catch { continue } // just let go of
|
|
52
|
+
if (Date.now() - since > LOCK_STALE) { try { fs.unlinkSync(lock) } catch { /* another app broke it first */ } continue }
|
|
53
|
+
if (Date.now() > end) throw new Error('the keeping outbox is locked by another app', { cause: error })
|
|
54
|
+
pause(15)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try { return fn() } finally {
|
|
58
|
+
fs.closeSync(fd)
|
|
59
|
+
try { fs.unlinkSync(lock) } catch { /* already broken as stale */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether an entry is held by another app now: its lease is not over, and its app is not gone. */
|
|
64
|
+
function heldByOther(entry, at) {
|
|
65
|
+
const lease = entry.lease
|
|
66
|
+
if (!lease || !(lease.until > at) || lease.token === ME.token) return false
|
|
67
|
+
if (lease.host !== ME.host) return true // another machine's: its time alone says
|
|
68
|
+
try { process.kill(lease.pid, 0); return true } catch (error) { return error.code === 'EPERM' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function read() {
|
|
72
|
+
try { return JSON.parse(fs.readFileSync(file(), 'utf8')).jobs || [] } catch { return [] }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Written whole or not at all (another app may be reading it). */
|
|
76
|
+
function write(jobs) {
|
|
77
|
+
const target = file()
|
|
78
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
79
|
+
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
80
|
+
fs.writeFileSync(tmp, JSON.stringify({ jobs }), { mode: 0o600 })
|
|
81
|
+
fs.renameSync(tmp, target)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Changes one entry (or removes it, when `fn` returns null), read afresh under the lock: the
|
|
85
|
+
* other app may have written. */
|
|
86
|
+
function change(jobId, fn) {
|
|
87
|
+
locked(() => {
|
|
88
|
+
const jobs = read()
|
|
89
|
+
const at = jobs.findIndex((j) => j.job === jobId)
|
|
90
|
+
if (at < 0) return
|
|
91
|
+
const next = fn(jobs[at])
|
|
92
|
+
if (next) jobs[at] = next; else jobs.splice(at, 1)
|
|
93
|
+
write(jobs)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A render done for the account `uid`, on the GPU server `server`: to be kept with it. */
|
|
98
|
+
function add(server, jobId, uid) {
|
|
99
|
+
if (!jobId || !uid) return
|
|
100
|
+
locked(() => {
|
|
101
|
+
const jobs = read()
|
|
102
|
+
if (jobs.some((j) => j.job === jobId)) return
|
|
103
|
+
jobs.push({ server, job: jobId, uid, added: Date.now(), tries: 0, next: 0 })
|
|
104
|
+
write(jobs)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The next render of `uid` that is due and nobody else holds, taken by this app (its lease
|
|
109
|
+
* written before it is worked on), or null. */
|
|
110
|
+
function claim(uid, at, seen) {
|
|
111
|
+
return locked(() => {
|
|
112
|
+
const jobs = read()
|
|
113
|
+
const due = jobs.find((j) => j.uid === uid && (j.next || 0) <= at && !seen.has(j.job) && !heldByOther(j, at))
|
|
114
|
+
if (!due) return null
|
|
115
|
+
due.lease = { ...ME, until: at + LEASE }
|
|
116
|
+
write(jobs)
|
|
117
|
+
return { ...due }
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What is still to be kept: [{ server, job, uid, added, tries, next, error }]. */
|
|
122
|
+
const pending = () => read()
|
|
123
|
+
|
|
124
|
+
let running = null
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Keeps every render of the signed-in account that is due, one at a time; one run at a time in
|
|
128
|
+
* this app (a call while it runs gets the same run). `onState(jobId, state)` follows each: the
|
|
129
|
+
* compression's progress, the upload, then { stage: 'kept', stats } — or { stage: 'failed', error,
|
|
130
|
+
* again } when it is tried again later. Resolves to how many were kept.
|
|
131
|
+
*/
|
|
132
|
+
function drain(cloud, { onState = () => {}, now = () => Date.now() } = {}) {
|
|
133
|
+
if (running) return running
|
|
134
|
+
running = (async () => {
|
|
135
|
+
const uid = await cloud.uid()
|
|
136
|
+
if (!uid) return 0
|
|
137
|
+
let kept = 0
|
|
138
|
+
const seen = new Set()
|
|
139
|
+
for (;;) {
|
|
140
|
+
const due = claim(uid, now(), seen)
|
|
141
|
+
if (!due) return kept
|
|
142
|
+
seen.add(due.job)
|
|
143
|
+
let last = null
|
|
144
|
+
// Held while it works: a compression takes minutes, the lease is renewed; this app gone,
|
|
145
|
+
// it runs out and another app takes the render up.
|
|
146
|
+
const renew = setInterval(() => {
|
|
147
|
+
try { change(due.job, (j) => (j.lease?.token === ME.token ? { ...j, lease: { ...j.lease, until: now() + LEASE } } : j)) } catch { /* next time */ }
|
|
148
|
+
}, RENEW)
|
|
149
|
+
renew.unref?.()
|
|
150
|
+
try {
|
|
151
|
+
const done = await cloud.keepRender(due.server, due.job, (_, state) => { if (state.stage === 'kept') last = state; onState(due.job, state) })
|
|
152
|
+
change(due.job, () => null)
|
|
153
|
+
if (done) kept += 1
|
|
154
|
+
onState(due.job, last || { stage: done ? 'kept' : 'gone' })
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const tries = (due.tries || 0) + 1
|
|
157
|
+
const again = now() - (due.added || 0) < GIVE_UP
|
|
158
|
+
change(due.job, (j) => (again ? { ...j, lease: undefined, tries, next: now() + RETRY[Math.min(tries - 1, RETRY.length - 1)], error: error.message } : null))
|
|
159
|
+
onState(due.job, { stage: 'failed', error: error.message, again })
|
|
160
|
+
} finally {
|
|
161
|
+
clearInterval(renew)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})().finally(() => { running = null })
|
|
165
|
+
return running
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Waits for the run in progress, if any (the CLI, before it exits). */
|
|
169
|
+
const settled = () => running || Promise.resolve(0)
|
|
170
|
+
|
|
171
|
+
module.exports = { add, pending, drain, settled, RETRY, GIVE_UP, LEASE }
|