pinokiod 5.1.10 → 5.1.11

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.
Files changed (56) hide show
  1. package/kernel/api/fs/download_worker.js +158 -0
  2. package/kernel/api/fs/index.js +95 -91
  3. package/kernel/api/index.js +3 -0
  4. package/kernel/bin/index.js +5 -2
  5. package/kernel/environment.js +19 -2
  6. package/kernel/git.js +972 -1
  7. package/kernel/index.js +65 -30
  8. package/kernel/peer.js +1 -2
  9. package/kernel/plugin.js +0 -8
  10. package/kernel/procs.js +92 -36
  11. package/kernel/prototype.js +45 -22
  12. package/kernel/shells.js +30 -6
  13. package/kernel/sysinfo.js +33 -13
  14. package/kernel/util.js +61 -24
  15. package/kernel/workspace_status.js +131 -7
  16. package/package.json +1 -1
  17. package/pipe/index.js +1 -1
  18. package/server/index.js +1169 -350
  19. package/server/public/create-launcher.js +157 -2
  20. package/server/public/install.js +135 -41
  21. package/server/public/style.css +32 -1
  22. package/server/public/tab-link-popover.js +45 -14
  23. package/server/public/terminal-settings.js +51 -35
  24. package/server/public/urldropdown.css +89 -3
  25. package/server/socket.js +12 -7
  26. package/server/views/agents.ejs +4 -3
  27. package/server/views/app.ejs +798 -30
  28. package/server/views/bootstrap.ejs +2 -1
  29. package/server/views/checkpoints.ejs +1014 -0
  30. package/server/views/checkpoints_registry_beta.ejs +260 -0
  31. package/server/views/columns.ejs +4 -4
  32. package/server/views/connect.ejs +1 -0
  33. package/server/views/d.ejs +74 -4
  34. package/server/views/download.ejs +28 -28
  35. package/server/views/editor.ejs +4 -5
  36. package/server/views/env_editor.ejs +1 -1
  37. package/server/views/file_explorer.ejs +1 -1
  38. package/server/views/index.ejs +3 -1
  39. package/server/views/init/index.ejs +2 -1
  40. package/server/views/install.ejs +2 -1
  41. package/server/views/net.ejs +9 -7
  42. package/server/views/network.ejs +15 -14
  43. package/server/views/pro.ejs +5 -2
  44. package/server/views/prototype/index.ejs +2 -1
  45. package/server/views/registry_link.ejs +76 -0
  46. package/server/views/rows.ejs +4 -4
  47. package/server/views/screenshots.ejs +1 -0
  48. package/server/views/settings.ejs +1 -0
  49. package/server/views/shell.ejs +4 -6
  50. package/server/views/terminal.ejs +528 -38
  51. package/server/views/tools.ejs +1 -0
  52. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764297248545 +0 -45
  53. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764335557118 +0 -45
  54. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764335834126 +0 -45
  55. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/events +0 -12
  56. package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/latest +0 -45
package/kernel/git.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const git = require('isomorphic-git')
2
2
  const fs = require('fs')
3
3
  const path = require('path')
4
+ const crypto = require('crypto')
4
5
  const { glob, sync, hasMagic } = require('glob-gitignore')
5
6
  const http = require('isomorphic-git/http/node')
6
7
  const ini = require('ini')
@@ -10,6 +11,112 @@ class Git {
10
11
  this.kernel = kernel
11
12
  this.dirs = new Set()
12
13
  this.mapping = {}
14
+ // In-memory manifest of checkpoints keyed by normalized remote, persisted under ~/pinokio/checkpoints/
15
+ this.history = {
16
+ version: "1",
17
+ apps: {},
18
+ commits: {}
19
+ }
20
+ // Active snapshot restore flags keyed by workspace name
21
+ this.activeSnapshot = {}
22
+ }
23
+ normalizeRemote(remote) {
24
+ if (!remote || typeof remote !== "string") return null
25
+ let str = remote.trim()
26
+ // Remove protocol prefixes
27
+ str = str.replace(/^(https?:\/\/|ssh:\/\/)/i, '')
28
+ // Convert scp-like git@host:path to host/path
29
+ const atIdx = str.indexOf('@')
30
+ if (atIdx !== -1) {
31
+ str = str.slice(atIdx + 1)
32
+ }
33
+ const firstSlashIdx = str.indexOf('/')
34
+ const colonIdx = str.indexOf(':')
35
+ if (colonIdx !== -1 && (firstSlashIdx === -1 || colonIdx < firstSlashIdx)) {
36
+ const host = str.slice(0, colonIdx)
37
+ const pathPart = str.slice(colonIdx + 1)
38
+ str = `${host}/${pathPart}`
39
+ }
40
+ // Lowercase host portion
41
+ const firstSlash = str.indexOf('/')
42
+ if (firstSlash !== -1) {
43
+ const host = str.slice(0, firstSlash).toLowerCase()
44
+ const rest = str.slice(firstSlash + 1)
45
+ str = rest ? `${host}/${rest}` : host
46
+ } else {
47
+ str = str.toLowerCase()
48
+ }
49
+ // Drop trailing .git
50
+ if (str.endsWith('.git')) {
51
+ str = str.slice(0, -4)
52
+ }
53
+ return str
54
+ }
55
+ normalizeGitPerson(person) {
56
+ if (!person || typeof person !== "object") return null
57
+ const name = typeof person.name === "string" ? person.name : null
58
+ const email = typeof person.email === "string" ? person.email : null
59
+ const timestamp = Number.isFinite(person.timestamp) ? person.timestamp : null
60
+ const timezoneOffset = Number.isFinite(person.timezoneOffset) ? person.timezoneOffset : null
61
+ if (!name && !email && !timestamp && !timezoneOffset) return null
62
+ return { name, email, timestamp, timezoneOffset }
63
+ }
64
+ isCommitSha(value) {
65
+ return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value.trim())
66
+ }
67
+ formatGitIsoTimestamp(timestampSeconds, timezoneOffsetMinutes) {
68
+ if (!Number.isFinite(timestampSeconds)) return null
69
+ const offsetMinutes = Number.isFinite(timezoneOffsetMinutes) ? timezoneOffsetMinutes : 0
70
+ const epochMs = Math.trunc(timestampSeconds) * 1000
71
+ const localMs = epochMs + offsetMinutes * 60 * 1000
72
+ const d = new Date(localMs)
73
+ if (!Number.isFinite(d.getTime())) return null
74
+ const pad2 = (n) => String(n).padStart(2, "0")
75
+ const y = d.getUTCFullYear()
76
+ const m = pad2(d.getUTCMonth() + 1)
77
+ const day = pad2(d.getUTCDate())
78
+ const hh = pad2(d.getUTCHours())
79
+ const mm = pad2(d.getUTCMinutes())
80
+ const ss = pad2(d.getUTCSeconds())
81
+ const sign = offsetMinutes >= 0 ? "+" : "-"
82
+ const abs = Math.abs(offsetMinutes)
83
+ const offH = pad2(Math.floor(abs / 60))
84
+ const offM = pad2(abs % 60)
85
+ return `${y}-${m}-${day}T${hh}:${mm}:${ss}${sign}${offH}:${offM}`
86
+ }
87
+ upsertCommitMeta(repoUrlNorm, sha, meta) {
88
+ if (!repoUrlNorm || typeof repoUrlNorm !== "string") return false
89
+ if (!this.isCommitSha(sha)) return false
90
+ const entry = meta && typeof meta === "object" ? meta : {}
91
+ const subject = typeof entry.subject === "string" && entry.subject.trim() ? entry.subject.trim() : null
92
+ const authorName = typeof entry.authorName === "string" && entry.authorName.trim() ? entry.authorName.trim() : null
93
+ const committedAt = typeof entry.committedAt === "string" && entry.committedAt.trim() ? entry.committedAt.trim() : null
94
+ if (!subject && !authorName && !committedAt) return false
95
+
96
+ const commits = this.commits()
97
+ if (!commits[repoUrlNorm] || typeof commits[repoUrlNorm] !== "object") {
98
+ commits[repoUrlNorm] = {}
99
+ }
100
+ const repoCommits = commits[repoUrlNorm]
101
+ const existing = repoCommits[sha] && typeof repoCommits[sha] === "object" ? repoCommits[sha] : null
102
+ if (!existing) {
103
+ repoCommits[sha] = { subject, authorName, committedAt }
104
+ return true
105
+ }
106
+ let changed = false
107
+ if (subject && (!existing.subject || existing.subject !== subject)) {
108
+ existing.subject = subject
109
+ changed = true
110
+ }
111
+ if (authorName && (!existing.authorName || existing.authorName !== authorName)) {
112
+ existing.authorName = authorName
113
+ changed = true
114
+ }
115
+ if (committedAt && (!existing.committedAt || existing.committedAt !== committedAt)) {
116
+ existing.committedAt = committedAt
117
+ changed = true
118
+ }
119
+ return changed
13
120
  }
14
121
  async init() {
15
122
  const ensureDir = (target) => fs.promises.mkdir(target, { recursive: true }).catch(() => { })
@@ -43,7 +150,8 @@ class Git {
43
150
 
44
151
  // best-effort: clear any stale index.lock files across all known repos at startup
45
152
  try {
46
- const repos = await this.repos(this.kernel.path("api"))
153
+ const apiRoot = this.kernel.path("api")
154
+ const repos = await this.repos(apiRoot)
47
155
  for (const repo of repos) {
48
156
  if (repo && repo.dir) {
49
157
  await this.clearStaleLock(repo.dir)
@@ -51,6 +159,857 @@ class Git {
51
159
  }
52
160
  } catch (_) {}
53
161
  }
162
+ checkpointsDir() {
163
+ return path.resolve(this.kernel.homedir, "checkpoints")
164
+ }
165
+ manifestPath() {
166
+ return path.resolve(this.checkpointsDir(), "manifest.json")
167
+ }
168
+ checkpointsPath() {
169
+ // Backward alias to manifest path for any callers using the old name internally
170
+ return this.manifestPath()
171
+ }
172
+ parseSha256Digest(digest) {
173
+ if (!digest || typeof digest !== "string") return null
174
+ const m = digest.match(/^sha256:([0-9a-f]{64})$/i)
175
+ if (!m) return null
176
+ return m[1].toLowerCase()
177
+ }
178
+ checkpointFilePath(hashDigest) {
179
+ const hex = this.parseSha256Digest(hashDigest)
180
+ if (!hex) return null
181
+ return path.resolve(this.checkpointsDir(), `sha256-${hex}.json`)
182
+ }
183
+ stableStringify(value) {
184
+ if (value === null || typeof value !== "object") return JSON.stringify(value)
185
+ if (Array.isArray(value)) return `[${value.map((v) => this.stableStringify(v)).join(",")}]`
186
+ const keys = Object.keys(value).sort()
187
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${this.stableStringify(value[k])}`).join(",")}}`
188
+ }
189
+ canonicalRepoUrl(remoteUrl) {
190
+ const remoteKey = this.normalizeRemote(remoteUrl)
191
+ if (!remoteKey) return null
192
+ return `https://${remoteKey}`.replace(/\/$/, "").toLowerCase()
193
+ }
194
+ canonicalizeCheckpoint({ root, repos, system }) {
195
+ const rootUrl = this.canonicalRepoUrl(root)
196
+ const canonicalRepos = Array.isArray(repos)
197
+ ? repos.map((r) => {
198
+ const pathVal = typeof r.path === "string" && r.path.length > 0 ? r.path : "."
199
+ const repo = r && (r.repo || r.remote) ? this.canonicalRepoUrl(r.repo || r.remote) : null
200
+ const commit = typeof r.commit === "string" && r.commit.length > 0 ? r.commit : null
201
+ return { path: pathVal, repo, commit }
202
+ })
203
+ : []
204
+ canonicalRepos.sort((a, b) => {
205
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1
206
+ const ra = a.repo || ""
207
+ const rb = b.repo || ""
208
+ if (ra !== rb) return ra < rb ? -1 : 1
209
+ const ca = a.commit || ""
210
+ const cb = b.commit || ""
211
+ if (ca !== cb) return ca < cb ? -1 : 1
212
+ return 0
213
+ })
214
+ return {
215
+ version: 1,
216
+ root: rootUrl,
217
+ repos: canonicalRepos,
218
+ }
219
+ }
220
+ hashCheckpoint(checkpoint) {
221
+ const canonical = this.canonicalizeCheckpoint(checkpoint || {})
222
+ const data = this.stableStringify(canonical)
223
+ const hex = crypto.createHash("sha256").update(data).digest("hex")
224
+ return { canonical, digest: `sha256:${hex}` }
225
+ }
226
+ normalizeReposArray(rawRepos, options = {}) {
227
+ const includeMeta = !!options.includeMeta
228
+ if (!Array.isArray(rawRepos)) {
229
+ return []
230
+ }
231
+ const repos = []
232
+ for (const repo of rawRepos) {
233
+ if (!repo) continue
234
+ const pathVal = typeof repo.path === "string" && repo.path.length > 0 ? repo.path : "."
235
+ let remote = typeof repo.remote === "string" && repo.remote.length > 0 ? repo.remote : null
236
+ if (!remote) remote = typeof repo.repo === "string" && repo.repo.length > 0 ? repo.repo : null
237
+ const commit = typeof repo.commit === "string" && repo.commit.length > 0 ? repo.commit : null
238
+ const entry = {
239
+ path: pathVal,
240
+ remote,
241
+ commit,
242
+ }
243
+ if (includeMeta) {
244
+ if (repo.message && typeof repo.message === "string") {
245
+ entry.message = repo.message
246
+ }
247
+ if (repo.author) {
248
+ const author = this.normalizeGitPerson(repo.author)
249
+ if (author) entry.author = author
250
+ }
251
+ if (repo.committer) {
252
+ const committer = this.normalizeGitPerson(repo.committer)
253
+ if (committer) entry.committer = committer
254
+ }
255
+
256
+ // Best-effort: enrich with persisted commit metadata (from local snapshots or registry imports).
257
+ if ((entry.message == null || entry.author == null) && remote && commit && this.isCommitSha(commit)) {
258
+ const repoUrlNorm = this.canonicalRepoUrl(remote)
259
+ const commits = repoUrlNorm ? this.commits() : null
260
+ const meta = commits && commits[repoUrlNorm] && typeof commits[repoUrlNorm] === "object"
261
+ ? commits[repoUrlNorm][commit]
262
+ : null
263
+ if (meta && typeof meta === "object") {
264
+ if (entry.message == null && meta.subject && typeof meta.subject === "string") {
265
+ entry.message = meta.subject
266
+ }
267
+ if (entry.author == null && meta.authorName && typeof meta.authorName === "string") {
268
+ let timestamp = null
269
+ if (meta.committedAt && typeof meta.committedAt === "string") {
270
+ const ms = Date.parse(meta.committedAt)
271
+ if (Number.isFinite(ms)) timestamp = Math.floor(ms / 1000)
272
+ }
273
+ entry.author = { name: meta.authorName, email: null, timestamp, timezoneOffset: null }
274
+ }
275
+ }
276
+ }
277
+ }
278
+ repos.push(entry)
279
+ }
280
+ repos.sort((a, b) => {
281
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1
282
+ const ra = a.remote || ""
283
+ const rb = b.remote || ""
284
+ if (ra !== rb) return ra < rb ? -1 : 1
285
+ const ca = a.commit || ""
286
+ const cb = b.commit || ""
287
+ if (ca !== cb) return ca < cb ? -1 : 1
288
+ return 0
289
+ })
290
+ return repos
291
+ }
292
+ apps() {
293
+ if (!this.history.apps || typeof this.history.apps !== "object") {
294
+ this.history.apps = {}
295
+ }
296
+ return this.history.apps
297
+ }
298
+ commits() {
299
+ if (!this.history.commits || typeof this.history.commits !== "object") {
300
+ this.history.commits = {}
301
+ }
302
+ return this.history.commits
303
+ }
304
+ remotes() {
305
+ // Alias for legacy callers; returns the apps map
306
+ return this.apps()
307
+ }
308
+ ensureApp(remoteUrl) {
309
+ const remoteKey = this.normalizeRemote(remoteUrl)
310
+ if (!remoteKey) return null
311
+ const apps = this.apps()
312
+ if (!apps[remoteKey]) {
313
+ apps[remoteKey] = { remote: remoteUrl, checkpoints: [] }
314
+ }
315
+ const entry = apps[remoteKey]
316
+ if (!Array.isArray(entry.checkpoints)) {
317
+ entry.checkpoints = []
318
+ }
319
+ if (!entry.remote && remoteUrl) {
320
+ entry.remote = remoteUrl
321
+ }
322
+ return { remoteKey, entry }
323
+ }
324
+ async writeCheckpointPayload(remoteKey, remoteUrl, { checkpoint, id, visibility, system } = {}) {
325
+ await fs.promises.mkdir(this.checkpointsDir(), { recursive: true }).catch(() => {})
326
+ const checkpointId = String(id || Date.now())
327
+ const vis = typeof visibility === "string" && visibility.trim() ? visibility.trim() : "public"
328
+ const hashed = this.hashCheckpoint(checkpoint)
329
+ const sys = system && typeof system === "object" ? system : null
330
+ const filePath = this.checkpointFilePath(hashed.digest)
331
+ if (!filePath) throw new Error("Invalid checkpoint hash")
332
+ try {
333
+ await fs.promises.access(filePath, fs.constants.F_OK)
334
+ } catch (_) {
335
+ await fs.promises.writeFile(filePath, JSON.stringify(hashed.canonical, null, 2))
336
+ }
337
+ const apps = this.apps()
338
+ if (!apps[remoteKey]) {
339
+ apps[remoteKey] = { remote: remoteUrl, checkpoints: [] }
340
+ }
341
+ const entry = apps[remoteKey]
342
+ const existingIdx = Array.isArray(entry.checkpoints)
343
+ ? entry.checkpoints.findIndex((c) => c && String(c.id) === checkpointId)
344
+ : -1
345
+ if (!Array.isArray(entry.checkpoints)) entry.checkpoints = []
346
+ if (existingIdx >= 0) {
347
+ const prev = entry.checkpoints[existingIdx] || {}
348
+ entry.checkpoints[existingIdx] = {
349
+ id: checkpointId,
350
+ hash: hashed.digest,
351
+ visibility: vis,
352
+ system: sys,
353
+ sync: prev.sync,
354
+ decision: prev.decision
355
+ }
356
+ } else {
357
+ entry.checkpoints.push({
358
+ id: checkpointId,
359
+ hash: hashed.digest,
360
+ visibility: vis,
361
+ system: sys,
362
+ sync: { status: "local" },
363
+ decision: null
364
+ })
365
+ }
366
+ // Keep checkpoints sorted newest-first if ids are time-based; fallback to string compare
367
+ entry.checkpoints.sort((a, b) => {
368
+ try {
369
+ const ai = BigInt(a.id)
370
+ const bi = BigInt(b.id)
371
+ return bi > ai ? 1 : bi < ai ? -1 : 0
372
+ } catch (_) {
373
+ return String(b.id).localeCompare(String(a.id))
374
+ }
375
+ })
376
+ await this.saveManifest()
377
+ return { id: checkpointId, hash: hashed.digest }
378
+ }
379
+ async readCheckpointPayload(id) {
380
+ const idStr = String(id)
381
+ const apps = this.apps()
382
+ let record = null
383
+ let remoteKey = null
384
+ for (const [k, entry] of Object.entries(apps)) {
385
+ if (!entry || !Array.isArray(entry.checkpoints)) continue
386
+ const hit = entry.checkpoints.find((c) => c && String(c.id) === idStr)
387
+ if (hit && hit.hash) {
388
+ record = hit
389
+ remoteKey = k
390
+ break
391
+ }
392
+ }
393
+ if (!record || !record.hash) return null
394
+ const filePath = this.checkpointFilePath(record.hash)
395
+ if (!filePath) return null
396
+ try {
397
+ const data = await fs.promises.readFile(filePath, "utf8")
398
+ const parsed = JSON.parse(data)
399
+ if (parsed && typeof parsed === "object") {
400
+ const sys = record.system && typeof record.system === "object" ? record.system : {}
401
+ return parsed
402
+ ? {
403
+ id: idStr,
404
+ hash: record.hash,
405
+ visibility: record.visibility || "public",
406
+ sync: record.sync || null,
407
+ decision: record.decision || null,
408
+ app: remoteKey,
409
+ root: parsed.root || null,
410
+ repos: Array.isArray(parsed.repos) ? parsed.repos : [],
411
+ system: sys,
412
+ platform: typeof sys.platform === "string" ? sys.platform : null,
413
+ arch: typeof sys.arch === "string" ? sys.arch : null,
414
+ gpu: typeof sys.gpu === "string" ? sys.gpu : null,
415
+ ram: typeof sys.ram === "number" ? sys.ram : null,
416
+ vram: typeof sys.vram === "number" ? sys.vram : null
417
+ }
418
+ : null
419
+ }
420
+ } catch (_) {}
421
+ return null
422
+ }
423
+ async getSnapshot(remoteKey, snapshotId) {
424
+ if (!remoteKey || snapshotId == null) return null
425
+ const idStr = String(snapshotId)
426
+ const apps = this.apps()
427
+ const entry = apps[remoteKey]
428
+ if (!entry || !Array.isArray(entry.checkpoints)) return null
429
+ const found = entry.checkpoints.find((c) => c && String(c.id) === idStr)
430
+ if (!found) return null
431
+ const payload = await this.readCheckpointPayload(idStr)
432
+ if (!payload) return null
433
+ return {
434
+ remoteKey,
435
+ remote: entry.remote || null,
436
+ snapshot: {
437
+ ...payload,
438
+ repos: this.normalizeReposArray(payload.repos || [], { includeMeta: true })
439
+ }
440
+ }
441
+ }
442
+ async findSnapshotById(snapshotId) {
443
+ const idStr = String(snapshotId)
444
+ const apps = this.apps()
445
+ for (const [remoteKey, entry] of Object.entries(apps)) {
446
+ if (!entry || !Array.isArray(entry.checkpoints)) continue
447
+ const hit = entry.checkpoints.find((c) => c && String(c.id) === idStr)
448
+ if (hit) {
449
+ const found = await this.getSnapshot(remoteKey, idStr)
450
+ if (found) return found
451
+ }
452
+ }
453
+ return null
454
+ }
455
+ async listSnapshotsForRemote(remoteKey) {
456
+ const apps = this.apps()
457
+ const entry = apps[remoteKey]
458
+ if (!entry || !Array.isArray(entry.checkpoints)) return []
459
+ const snapshots = []
460
+ for (const cp of entry.checkpoints) {
461
+ if (!cp || cp.id == null) continue
462
+ const payload = await this.readCheckpointPayload(cp.id)
463
+ if (!payload) continue
464
+ snapshots.push({
465
+ ...payload,
466
+ repos: this.normalizeReposArray(payload.repos || [], { includeMeta: true })
467
+ })
468
+ }
469
+ snapshots.sort((a, b) => {
470
+ try {
471
+ const ai = BigInt(a.id)
472
+ const bi = BigInt(b.id)
473
+ return bi > ai ? 1 : bi < ai ? -1 : 0
474
+ } catch (_) {
475
+ return String(b.id).localeCompare(String(a.id))
476
+ }
477
+ })
478
+ return snapshots
479
+ }
480
+ ensureRemote(remoteUrl) {
481
+ return this.ensureApp(remoteUrl)
482
+ }
483
+ async loadCheckpoints() {
484
+ let history = this.history
485
+ let needsPersist = false
486
+ try {
487
+ await fs.promises.mkdir(this.checkpointsDir(), { recursive: true })
488
+ } catch (_) {}
489
+ try {
490
+ const str = await fs.promises.readFile(this.manifestPath(), "utf8")
491
+ const parsed = JSON.parse(str)
492
+ if (parsed && typeof parsed === "object") {
493
+ history = parsed
494
+ } else {
495
+ needsPersist = true
496
+ }
497
+ } catch (_) {
498
+ needsPersist = true
499
+ }
500
+ if (!history || typeof history !== "object") {
501
+ history = { version: "1", apps: {} }
502
+ needsPersist = true
503
+ }
504
+ if (!history.apps || typeof history.apps !== "object") {
505
+ history.apps = {}
506
+ needsPersist = true
507
+ }
508
+ if (!history.commits || typeof history.commits !== "object") {
509
+ history.commits = {}
510
+ }
511
+ history.version = history.version || "1"
512
+ this.history = history
513
+ if (needsPersist) {
514
+ try {
515
+ await this.saveManifest()
516
+ } catch (_) {}
517
+ }
518
+ }
519
+ async saveManifest() {
520
+ const str = JSON.stringify(this.history, null, 2)
521
+ await fs.promises.writeFile(this.manifestPath(), str)
522
+ }
523
+ async setCheckpointSync(remoteKey, checkpointId, sync) {
524
+ if (!remoteKey || checkpointId == null) return false
525
+ const apps = this.apps()
526
+ const entry = apps[remoteKey]
527
+ if (!entry || !Array.isArray(entry.checkpoints)) return false
528
+ const idStr = String(checkpointId)
529
+ const idx = entry.checkpoints.findIndex((c) => c && String(c.id) === idStr)
530
+ if (idx < 0) return false
531
+ entry.checkpoints[idx].sync = sync
532
+ await this.saveManifest()
533
+ return true
534
+ }
535
+ getCheckpointDecision(remoteKey, checkpointId) {
536
+ if (!remoteKey || checkpointId == null) return null
537
+ const apps = this.apps()
538
+ const entry = apps[remoteKey]
539
+ if (!entry || !Array.isArray(entry.checkpoints)) return null
540
+ const idStr = String(checkpointId)
541
+ const hit = entry.checkpoints.find((c) => c && String(c.id) === idStr)
542
+ if (!hit) return null
543
+ return hit.decision || null
544
+ }
545
+ async setCheckpointDecision(remoteKey, checkpointId, decision) {
546
+ if (!remoteKey || checkpointId == null) return false
547
+ const apps = this.apps()
548
+ const entry = apps[remoteKey]
549
+ if (!entry || !Array.isArray(entry.checkpoints)) return false
550
+ const idStr = String(checkpointId)
551
+ const idx = entry.checkpoints.findIndex((c) => c && String(c.id) === idStr)
552
+ if (idx < 0) return false
553
+ const next = decision && String(decision).trim() ? String(decision).trim() : null
554
+ if (next) {
555
+ entry.checkpoints[idx].decision = next
556
+ } else {
557
+ delete entry.checkpoints[idx].decision
558
+ }
559
+ await this.saveManifest()
560
+ return true
561
+ }
562
+ async logCheckpointRestore(event) {
563
+ const logEntry = {
564
+ ts: Date.now(),
565
+ ...event,
566
+ }
567
+ try {
568
+ console.log("[checkpoints.restore]", logEntry)
569
+ } catch (_) {}
570
+ }
571
+ async appendWorkspaceSnapshot(workspaceName, repos) {
572
+ if (!workspaceName || !Array.isArray(repos)) return
573
+ const workspaceRoot = this.kernel.path("api", workspaceName)
574
+ const currentRepos = []
575
+ for (const repo of repos) {
576
+ if (!repo || !repo.gitParentPath || !repo.url) continue
577
+ const relPath = path.relative(workspaceRoot, repo.gitParentPath) || "."
578
+ let commit = null
579
+ let message = null
580
+ let author = null
581
+ let committer = null
582
+ try {
583
+ const head = await this.getHead(repo.gitParentPath)
584
+ commit = head && head.hash ? head.hash : null
585
+ message = head && head.message ? head.message : null
586
+ author = head && head.author ? this.normalizeGitPerson(head.author) : null
587
+ committer = head && head.committer ? this.normalizeGitPerson(head.committer) : null
588
+ } catch (_) {}
589
+ currentRepos.push({
590
+ path: relPath === "" ? "." : relPath,
591
+ remote: repo.url,
592
+ commit,
593
+ message,
594
+ author,
595
+ committer,
596
+ })
597
+ }
598
+ const normalizedCurrent = this.normalizeReposArray(currentRepos, { includeMeta: true })
599
+
600
+ // Record commit metadata for UI display (subject + authorName + committedAt) keyed by repo+sha.
601
+ let commitsDirty = false
602
+ for (const repo of normalizedCurrent) {
603
+ if (!repo || !repo.remote || !repo.commit) continue
604
+ const repoUrlNorm = this.canonicalRepoUrl(repo.remote)
605
+ if (!repoUrlNorm) continue
606
+ const sha = String(repo.commit).trim()
607
+ if (!this.isCommitSha(sha)) continue
608
+ const subject = typeof repo.message === "string" && repo.message.trim()
609
+ ? repo.message.split('\n')[0].trim()
610
+ : null
611
+ const authorName = repo.author && typeof repo.author.name === "string" ? repo.author.name : null
612
+ const committedAt = repo.committer
613
+ ? this.formatGitIsoTimestamp(repo.committer.timestamp, repo.committer.timezoneOffset)
614
+ : null
615
+ if (this.upsertCommitMeta(repoUrlNorm, sha, { subject, authorName, committedAt })) {
616
+ commitsDirty = true
617
+ }
618
+ }
619
+
620
+ const mainRepo = normalizedCurrent.find((repo) => repo && repo.path === "." && repo.remote)
621
+ if (!mainRepo) return
622
+ const remoteEntry = this.ensureApp(mainRepo.remote)
623
+ if (!remoteEntry) return
624
+ const { remoteKey, entry } = remoteEntry
625
+ const checkpoints = Array.isArray(entry.checkpoints) ? entry.checkpoints : []
626
+ const checkpoint = this.canonicalizeCheckpoint({
627
+ root: mainRepo.remote,
628
+ repos: normalizedCurrent.map((r) => ({ path: r.path, remote: r.remote, commit: r.commit })),
629
+ })
630
+ const hashed = this.hashCheckpoint(checkpoint)
631
+ if (checkpoints.length > 0) {
632
+ const last = checkpoints[0] // newest-first
633
+ if (last && last.hash && String(last.hash) === String(hashed.digest)) {
634
+ // No change; return the existing snapshot for this workspace version.
635
+ if (commitsDirty) {
636
+ try {
637
+ await this.saveManifest()
638
+ } catch (_) {}
639
+ }
640
+ return { id: String(last.id), hash: String(last.hash), remoteKey, deduped: true }
641
+ }
642
+ }
643
+ const id = String(Date.now())
644
+ const system = {
645
+ platform: this.kernel.platform || null,
646
+ arch: this.kernel.arch || null,
647
+ gpu: this.kernel.gpu_model || this.kernel.gpu || null,
648
+ ram: typeof this.kernel.ram === "number" ? this.kernel.ram : null,
649
+ vram: typeof this.kernel.vram === "number" ? this.kernel.vram : null,
650
+ }
651
+ const saved = await this.writeCheckpointPayload(remoteKey, mainRepo.remote, { checkpoint, id, visibility: "public", system })
652
+ return saved ? { ...saved, remoteKey } : saved
653
+ }
654
+ async downloadMainFromSnapshot(workspaceName, snapshotId, remoteOverride) {
655
+ if (!workspaceName || snapshotId == null) return false
656
+ const idStr = String(snapshotId)
657
+
658
+ // Prefer an explicit remote (used when installing into a new folder),
659
+ // otherwise try to infer via workspace .git, otherwise scan by id.
660
+ let found = null
661
+ if (remoteOverride) {
662
+ found = await this.findSnapshotByRemote(remoteOverride, idStr)
663
+ }
664
+ if (!found || !found.snapshot) {
665
+ try {
666
+ const workspaceRoot = this.kernel.path("api", workspaceName)
667
+ const mainRemote = await git.getConfig({
668
+ fs,
669
+ http,
670
+ dir: workspaceRoot,
671
+ path: 'remote.origin.url'
672
+ })
673
+ if (mainRemote) {
674
+ found = await this.findSnapshotByRemote(mainRemote, idStr)
675
+ }
676
+ } catch (_) {}
677
+ }
678
+ if (!found || !found.snapshot) {
679
+ found = await this.findSnapshotById(idStr)
680
+ }
681
+ if (!found || !found.snapshot) return false
682
+ const snap = found.snapshot
683
+ const repos = this.normalizeReposArray(snap.repos || [])
684
+ const mainRepo = repos.find((repo) => repo && repo.path === ".")
685
+ if (!mainRepo || !mainRepo.remote) return false
686
+ await this.logCheckpointRestore({
687
+ step: "main",
688
+ workspace: workspaceName,
689
+ snapshotId: idStr,
690
+ remote: mainRepo.remote,
691
+ commit: mainRepo.commit || null
692
+ })
693
+ const apiRoot = this.kernel.path("api")
694
+ const workspaceRoot = this.kernel.path("api", workspaceName)
695
+ const mainRoot = path.resolve(workspaceRoot, mainRepo.path || ".")
696
+ const mainGit = path.resolve(mainRoot, ".git")
697
+ try {
698
+ await fs.promises.mkdir(apiRoot, { recursive: true })
699
+ } catch (_) {}
700
+ let exists = false
701
+ try {
702
+ await fs.promises.access(mainGit, fs.constants.F_OK)
703
+ exists = true
704
+ } catch (_) {}
705
+ if (!exists) {
706
+ const remote = mainRepo.remote
707
+ const commit = mainRepo.commit
708
+ try {
709
+ await this.kernel.exec({
710
+ message: [`git clone ${remote} "${workspaceName}"`],
711
+ path: apiRoot
712
+ }, () => {})
713
+ await this.logCheckpointRestore({
714
+ step: "main-clone",
715
+ workspace: workspaceName,
716
+ snapshotId: idStr,
717
+ remote,
718
+ commit: commit || null,
719
+ status: "ok"
720
+ })
721
+ } catch (err) {
722
+ await this.logCheckpointRestore({
723
+ step: "main-clone",
724
+ workspace: workspaceName,
725
+ snapshotId: idStr,
726
+ remote,
727
+ commit: commit || null,
728
+ status: "error",
729
+ error: err && err.message ? err.message : String(err)
730
+ })
731
+ }
732
+ if (commit) {
733
+ try {
734
+ await this.kernel.exec({
735
+ message: [
736
+ "git fetch --all --tags",
737
+ `git checkout --detach ${commit}`
738
+ ],
739
+ path: workspaceRoot
740
+ }, () => {})
741
+ await this.logCheckpointRestore({
742
+ step: "main-checkout",
743
+ workspace: workspaceName,
744
+ snapshotId: idStr,
745
+ remote,
746
+ commit,
747
+ status: "ok"
748
+ })
749
+ } catch (err) {
750
+ await this.logCheckpointRestore({
751
+ step: "main-checkout",
752
+ workspace: workspaceName,
753
+ snapshotId: idStr,
754
+ remote,
755
+ commit,
756
+ status: "error",
757
+ error: err && err.message ? err.message : String(err)
758
+ })
759
+ }
760
+ }
761
+ try {
762
+ await fs.promises.access(mainGit, fs.constants.F_OK)
763
+ exists = true
764
+ } catch (_) {
765
+ exists = false
766
+ }
767
+ }
768
+ if (exists) {
769
+ try {
770
+ await this.applyPinnedCommitsForSnapshot({
771
+ workspaceName,
772
+ workspaceRoot,
773
+ remoteKey: found.remoteKey,
774
+ snapshotId: idStr,
775
+ repos,
776
+ skipMain: true,
777
+ })
778
+ } catch (err) {
779
+ await this.logCheckpointRestore({
780
+ step: "sub-checkout",
781
+ workspace: workspaceName,
782
+ snapshotId: idStr,
783
+ status: "error",
784
+ error: err && err.message ? err.message : String(err)
785
+ })
786
+ }
787
+ }
788
+ return exists
789
+ }
790
+ async applyPinnedCommitsForSnapshot({ workspaceName, workspaceRoot, remoteKey, snapshotId, repos, skipMain = false }) {
791
+ if (!Array.isArray(repos)) return
792
+ if (!workspaceName || !workspaceRoot || !remoteKey || snapshotId == null) return
793
+ const found = await this.getSnapshot(remoteKey, snapshotId)
794
+ const snapRepos = found && found.snapshot && Array.isArray(found.snapshot.repos) ? found.snapshot.repos : null
795
+ if (!snapRepos) return
796
+ for (const repo of repos) {
797
+ if (!repo || !repo.remote) continue
798
+ if (skipMain && repo.path === ".") continue
799
+ const repoPath = path.resolve(workspaceRoot, repo.path || ".")
800
+ const gitPath = path.resolve(repoPath, ".git")
801
+ let repoExists = false
802
+ try {
803
+ await fs.promises.access(gitPath, fs.constants.F_OK)
804
+ repoExists = true
805
+ } catch (_) {}
806
+ if (!repoExists) {
807
+ await this.logCheckpointRestore({
808
+ step: "sub-checkout",
809
+ workspace: workspaceName,
810
+ snapshotId,
811
+ remote: repo.remote,
812
+ path: repoPath,
813
+ status: "skip",
814
+ reason: "repo missing"
815
+ })
816
+ continue
817
+ }
818
+ const pin = snapRepos.find((r) => r && r.remote === repo.remote && r.commit)
819
+ if (!pin || !pin.commit) {
820
+ await this.logCheckpointRestore({
821
+ step: "sub-checkout",
822
+ workspace: workspaceName,
823
+ snapshotId,
824
+ remote: repo.remote,
825
+ path: repoPath,
826
+ status: "skip",
827
+ reason: "no pinned commit for repo in snapshot"
828
+ })
829
+ continue
830
+ }
831
+ await this.logCheckpointRestore({
832
+ step: "sub-checkout",
833
+ workspace: workspaceName,
834
+ snapshotId,
835
+ remote: repo.remote,
836
+ commit: pin.commit,
837
+ path: repoPath,
838
+ status: "begin"
839
+ })
840
+ try {
841
+ await this.kernel.exec({
842
+ message: [
843
+ "git fetch --all --tags",
844
+ `git checkout --detach ${pin.commit}`
845
+ ],
846
+ path: repoPath
847
+ }, () => {})
848
+ await this.logCheckpointRestore({
849
+ step: "sub-checkout",
850
+ workspace: workspaceName,
851
+ snapshotId,
852
+ remote: repo.remote,
853
+ commit: pin.commit,
854
+ path: repoPath,
855
+ status: "ok"
856
+ })
857
+ } catch (err) {
858
+ await this.logCheckpointRestore({
859
+ step: "sub-checkout",
860
+ workspace: workspaceName,
861
+ snapshotId,
862
+ remote: repo.remote,
863
+ commit: pin.commit,
864
+ path: repoPath,
865
+ status: "error",
866
+ error: err && err.message ? err.message : String(err)
867
+ })
868
+ }
869
+ }
870
+ }
871
+ async restoreNewReposForActiveSnapshot(workspaceName, workspaceRoot, beforeDirs) {
872
+ if (!workspaceName || !workspaceRoot || !beforeDirs) return
873
+ const active = this.activeSnapshot && this.activeSnapshot[workspaceName]
874
+ const snapshotId = typeof active === "object" && active !== null ? active.id : active
875
+ const remoteKeyHint = typeof active === "object" && active !== null ? active.remoteKey : null
876
+ if (!snapshotId) {
877
+ await this.logCheckpointRestore({
878
+ step: "sub-checkout",
879
+ workspace: workspaceName,
880
+ status: "skip",
881
+ reason: "no active snapshot id"
882
+ })
883
+ return
884
+ }
885
+ let found = null
886
+ if (remoteKeyHint) {
887
+ found = await this.findSnapshotByRemote(remoteKeyHint, snapshotId)
888
+ }
889
+ if (!found || !found.snapshot) {
890
+ found = await this.findSnapshotForFolder(workspaceName, snapshotId)
891
+ }
892
+ if (!found || !found.snapshot) {
893
+ await this.logCheckpointRestore({
894
+ step: "sub-checkout",
895
+ workspace: workspaceName,
896
+ snapshotId,
897
+ status: "skip",
898
+ reason: "snapshot not found for workspace"
899
+ })
900
+ return
901
+ }
902
+ const snap = found.snapshot
903
+ const snapRepos = Array.isArray(snap.repos) ? snap.repos : []
904
+ const reposAfter = await this.repos(workspaceRoot)
905
+ const newRepos = reposAfter.filter((repo) => {
906
+ return repo && repo.gitParentPath && !beforeDirs.has(repo.gitParentPath)
907
+ })
908
+ await this.logCheckpointRestore({
909
+ step: "sub-checkout",
910
+ workspace: workspaceName,
911
+ snapshotId,
912
+ status: "scan",
913
+ newRepoCount: newRepos.length
914
+ })
915
+ for (const repo of newRepos) {
916
+ if (!repo || !repo.url) continue
917
+ const repoKey = this.normalizeRemote(repo.url)
918
+ const pin = repoKey ? snapRepos.find((r) => r && this.normalizeRemote(r.remote) === repoKey && r.commit) : null
919
+ if (pin && pin.commit) {
920
+ await this.logCheckpointRestore({
921
+ workspace: workspaceName,
922
+ snapshotId,
923
+ remote: repo.url,
924
+ commit: pin.commit,
925
+ path: repo.gitParentPath,
926
+ step: "sub-checkout",
927
+ status: "begin"
928
+ })
929
+ try {
930
+ await this.kernel.exec({
931
+ message: [
932
+ "git fetch --all --tags",
933
+ `git checkout --detach ${pin.commit}`
934
+ ],
935
+ path: repo.gitParentPath
936
+ }, () => {})
937
+ await this.logCheckpointRestore({
938
+ workspace: workspaceName,
939
+ snapshotId,
940
+ remote: repo.url,
941
+ commit: pin.commit,
942
+ path: repo.gitParentPath,
943
+ step: "sub-checkout",
944
+ status: "ok"
945
+ })
946
+ } catch (err) {
947
+ await this.logCheckpointRestore({
948
+ workspace: workspaceName,
949
+ snapshotId,
950
+ remote: repo.url,
951
+ commit: pin.commit,
952
+ path: repo.gitParentPath,
953
+ step: "sub-checkout",
954
+ status: "error",
955
+ error: err && err.message ? err.message : String(err)
956
+ })
957
+ }
958
+ } else {
959
+ await this.logCheckpointRestore({
960
+ workspace: workspaceName,
961
+ snapshotId,
962
+ remote: repo.url,
963
+ path: repo.gitParentPath,
964
+ step: "sub-checkout",
965
+ status: "skip",
966
+ reason: "no pinned commit for repo in snapshot"
967
+ })
968
+ }
969
+ }
970
+ }
971
+ async findPinnedCommitForSnapshot(remoteKey, snapshotId, remoteUrl) {
972
+ // Look up a commit for a specific snapshot id and remote
973
+ if (!remoteKey || !remoteUrl) return null
974
+ const found = await this.getSnapshot(remoteKey, snapshotId)
975
+ if (!found || !found.snapshot || !Array.isArray(found.snapshot.repos)) return null
976
+ const targetKey = this.normalizeRemote(remoteUrl)
977
+ if (!targetKey) return null
978
+ for (const repo of found.snapshot.repos) {
979
+ if (repo && this.normalizeRemote(repo.remote) === targetKey && repo.commit) {
980
+ return { commit: repo.commit, path: repo.path }
981
+ }
982
+ }
983
+ return null
984
+ }
985
+ async findSnapshotByRemote(remoteUrl, snapshotId) {
986
+ if (!remoteUrl || snapshotId == null) return null
987
+ const apps = this.apps()
988
+ const targetKey = this.normalizeRemote(remoteUrl)
989
+ if (!targetKey) return null
990
+ if (!apps[targetKey]) return null
991
+ return this.getSnapshot(targetKey, snapshotId)
992
+ }
993
+ async findSnapshotForFolder(folderName, snapshotId) {
994
+ if (!folderName || snapshotId == null) return null
995
+ const idStr = String(snapshotId)
996
+ // Try the workspace's git remote first
997
+ try {
998
+ const workspaceRoot = this.kernel.path("api", folderName)
999
+ const mainRemote = await git.getConfig({
1000
+ fs,
1001
+ http,
1002
+ dir: workspaceRoot,
1003
+ path: 'remote.origin.url'
1004
+ })
1005
+ if (mainRemote) {
1006
+ const found = await this.findSnapshotByRemote(mainRemote, idStr)
1007
+ if (found) return found
1008
+ }
1009
+ } catch (_) {}
1010
+ // Fallback: any snapshot matching the id
1011
+ return this.findSnapshotById(idStr)
1012
+ }
54
1013
  async ensureDefaults(homeOverride) {
55
1014
  const home = homeOverride || this.kernel.homedir
56
1015
  if (!home) return
@@ -272,6 +1231,18 @@ class Git {
272
1231
  return {
273
1232
  hash: oid,
274
1233
  message: commit.message,
1234
+ author: commit.author ? {
1235
+ name: commit.author.name,
1236
+ email: commit.author.email,
1237
+ timestamp: commit.author.timestamp,
1238
+ timezoneOffset: commit.author.timezoneOffset
1239
+ } : null,
1240
+ committer: commit.committer ? {
1241
+ name: commit.committer.name,
1242
+ email: commit.committer.email,
1243
+ timestamp: commit.committer.timestamp,
1244
+ timezoneOffset: commit.committer.timezoneOffset
1245
+ } : null,
275
1246
  };
276
1247
  }
277
1248