pinokiod 5.1.5 → 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.
- package/kernel/api/fs/download_worker.js +158 -0
- package/kernel/api/fs/index.js +95 -91
- package/kernel/api/index.js +3 -0
- package/kernel/bin/index.js +5 -2
- package/kernel/environment.js +19 -2
- package/kernel/git.js +972 -1
- package/kernel/index.js +65 -30
- package/kernel/peer.js +1 -2
- package/kernel/plugin.js +0 -8
- package/kernel/procs.js +92 -36
- package/kernel/prototype.js +45 -22
- package/kernel/shells.js +30 -6
- package/kernel/sysinfo.js +33 -13
- package/kernel/util.js +61 -24
- package/kernel/workspace_status.js +131 -7
- package/package.json +1 -1
- package/pipe/index.js +1 -1
- package/server/index.js +1173 -348
- package/server/public/create-launcher.js +157 -2
- package/server/public/install.js +135 -41
- package/server/public/style.css +32 -1
- package/server/public/tab-link-popover.js +45 -14
- package/server/public/terminal-settings.js +51 -35
- package/server/public/urldropdown.css +89 -3
- package/server/socket.js +12 -7
- package/server/views/agents.ejs +4 -3
- package/server/views/app.ejs +798 -30
- package/server/views/bootstrap.ejs +2 -1
- package/server/views/checkpoints.ejs +1014 -0
- package/server/views/checkpoints_registry_beta.ejs +260 -0
- package/server/views/columns.ejs +4 -4
- package/server/views/connect.ejs +1 -0
- package/server/views/d.ejs +74 -4
- package/server/views/download.ejs +28 -28
- package/server/views/editor.ejs +4 -5
- package/server/views/env_editor.ejs +1 -1
- package/server/views/file_explorer.ejs +1 -1
- package/server/views/index.ejs +3 -1
- package/server/views/init/index.ejs +2 -1
- package/server/views/install.ejs +2 -1
- package/server/views/net.ejs +9 -7
- package/server/views/network.ejs +15 -14
- package/server/views/pro.ejs +5 -2
- package/server/views/prototype/index.ejs +2 -1
- package/server/views/registry_link.ejs +76 -0
- package/server/views/rows.ejs +4 -4
- package/server/views/screenshots.ejs +1 -0
- package/server/views/settings.ejs +1 -0
- package/server/views/shell.ejs +4 -6
- package/server/views/terminal.ejs +528 -38
- package/server/views/tools.ejs +1 -0
- package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/1764297248545 +0 -45
- package/undefined/logs/dev/plugin/cursor-agent.git/pinokio.js/events +0 -4
- 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
|
|
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
|
|