@davait/aipictury 1.19.5 → 1.20.2

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/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
- function save({ kind = 'report', description = '', versions = {}, settings = {}, log = [], extra = {} }) {
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 file = path.join(dir(), `${stamp}-${kind}.json`)
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
- fs.writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 })
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
- module.exports = { redact, redactDeep, save, dir }
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, all or nothing (in commits of up to 400). Each: { set: [path, data] } or { delete: path }. */
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
- ? { delete: `${this.root}/${w.delete}` }
125
- : { update: { name: `${this.root}/${w.set[0]}`, fields: encodeFields(w.set[1]) } }))
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 its tools work between model calls, and lets go when it ends.
6
- // Another app holding the GPU: the run does not start, and says who has it.
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
- const beat = held ? setInterval(() => {
81
- fetch(`${server}/api/gpu/hold`, { method: 'POST', headers: h, signal: AbortSignal.timeout(10_000) }).catch(() => {})
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 }