@foldrun/core 1.4.2 → 2.0.0
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/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/src/deploy.d.ts +0 -26
- package/dist/src/deploy.js +7 -52
- package/dist/src/history.d.ts +30 -10
- package/dist/src/history.js +232 -99
- package/dist/src/library.d.ts +0 -13
- package/dist/src/library.js +0 -50
- package/dist/src/platform.d.ts +1 -3
- package/dist/src/platform.js +0 -1
- package/dist/src/run-container.d.ts +29 -0
- package/dist/src/run-container.js +63 -2
- package/dist/src/runner.js +10 -2
- package/dist/src/secrets.js +1 -12
- package/dist/src/starter.js +2 -2
- package/dist/src/store.d.ts +15 -10
- package/dist/src/store.js +45 -21
- package/index.ts +0 -3
- package/package.json +1 -3
- package/src/deploy.ts +2 -70
- package/src/history.ts +230 -108
- package/src/library.ts +0 -44
- package/src/platform.ts +1 -4
- package/src/run-container.ts +68 -2
- package/src/runner.ts +10 -1
- package/src/secrets.ts +1 -11
- package/src/starter.ts +2 -2
- package/src/store.ts +55 -27
- package/dist/src/git-hook.d.ts +0 -1
- package/dist/src/git-hook.js +0 -70
- package/dist/src/git.d.ts +0 -37
- package/dist/src/git.js +0 -101
- package/dist/src/gitrepo.d.ts +0 -113
- package/dist/src/gitrepo.js +0 -404
- package/dist/src/tar.d.ts +0 -30
- package/dist/src/tar.js +0 -112
- package/src/git-hook.ts +0 -73
- package/src/git.ts +0 -117
- package/src/gitrepo.ts +0 -439
- package/src/tar.ts +0 -125
package/dist/index.d.ts
CHANGED
|
@@ -24,7 +24,6 @@ export * from "./src/tool-names.ts";
|
|
|
24
24
|
export * from "./src/history.ts";
|
|
25
25
|
export * from "./src/zip.ts";
|
|
26
26
|
export * from "./src/package.ts";
|
|
27
|
-
export * from "./src/gitrepo.ts";
|
|
28
27
|
export * from "./src/storage.ts";
|
|
29
28
|
export * from "./src/secrets.ts";
|
|
30
29
|
export * from "./src/oauth-presets.ts";
|
|
@@ -35,5 +34,3 @@ export * from "./src/approvals.ts";
|
|
|
35
34
|
export * from "./src/deploy.ts";
|
|
36
35
|
export * from "./src/agent-import.ts";
|
|
37
36
|
export * from "./src/account.ts";
|
|
38
|
-
export * from "./src/tar.ts";
|
|
39
|
-
export * from "./src/git.ts";
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,6 @@ export * from "./src/tool-names.js";
|
|
|
35
35
|
export * from "./src/history.js";
|
|
36
36
|
export * from "./src/zip.js";
|
|
37
37
|
export * from "./src/package.js";
|
|
38
|
-
export * from "./src/gitrepo.js";
|
|
39
38
|
export * from "./src/storage.js";
|
|
40
39
|
export * from "./src/secrets.js";
|
|
41
40
|
export * from "./src/oauth-presets.js";
|
|
@@ -46,5 +45,3 @@ export * from "./src/approvals.js";
|
|
|
46
45
|
export * from "./src/deploy.js";
|
|
47
46
|
export * from "./src/agent-import.js";
|
|
48
47
|
export * from "./src/account.js";
|
|
49
|
-
export * from "./src/tar.js";
|
|
50
|
-
export * from "./src/git.js";
|
package/dist/src/deploy.d.ts
CHANGED
|
@@ -124,32 +124,6 @@ export interface DeployOptions {
|
|
|
124
124
|
* webhook or a CLI that needs to report *why*, and every reason is already in
|
|
125
125
|
* the plan.
|
|
126
126
|
*/
|
|
127
|
-
/** What a push recorded about itself, where the Repository page reads it. */
|
|
128
|
-
export interface PushNote {
|
|
129
|
-
commit: string;
|
|
130
|
-
at: string;
|
|
131
|
-
applied: boolean;
|
|
132
|
-
issues?: unknown[];
|
|
133
|
-
blockedBy?: string[];
|
|
134
|
-
/** Set when a blocked push was applied later, by the retry. */
|
|
135
|
-
appliedAt?: string;
|
|
136
|
-
}
|
|
137
|
-
export declare const pushNotePath: (tenant: string, workspace: string) => string;
|
|
138
|
-
export declare function readPushNote(tenant: string, workspace: string): PushNote | null;
|
|
139
|
-
export declare function writePushNote(tenant: string, workspace: string, note: PushNote): void;
|
|
140
|
-
/**
|
|
141
|
-
* A push whose deploy was refused only because a run was in flight is not a
|
|
142
|
-
* failure — it is a deploy that has not happened yet. Git already said the
|
|
143
|
-
* push succeeded (the deploy happens after the client is gone), so nothing
|
|
144
|
-
* would ever tell anyone the workspace on disk is behind `main`. This applies
|
|
145
|
-
* it once the runs are done.
|
|
146
|
-
*
|
|
147
|
-
* Only `blockedBy`: a push refused for `issues` is a broken workspace and
|
|
148
|
-
* wants a person, not a retry.
|
|
149
|
-
*/
|
|
150
|
-
export declare function applyPendingPush(tenant: string, workspace: string, filesAt: (tenant: string, scope: string, ref: string) => DeployFile[]): {
|
|
151
|
-
commit: string;
|
|
152
|
-
} | null;
|
|
153
127
|
export declare function deployWorkspace(tenant: string, workspace: string, files: DeployFile[], opts?: DeployOptions): DeployResult;
|
|
154
128
|
export declare function writeDeployedCommit(tenant: string, workspace: string, commit: string): void;
|
|
155
129
|
export declare function deployedCommit(tenant: string, workspace: string): {
|
package/dist/src/deploy.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
23
|
-
import { saveWorkspace, keptOnDeploy, workspaceDir,
|
|
23
|
+
import { saveWorkspace, keptOnDeploy, workspaceDir, listRunSummaries, parseFlow, parseToolDef, WORKSPACE_DIRS, assertSafeName, } from "./store.js";
|
|
24
24
|
import { lintFlow } from "./flow-lint.js";
|
|
25
25
|
import { refNames } from "./refs.js";
|
|
26
26
|
import matter from "gray-matter";
|
|
@@ -32,7 +32,6 @@ import { networkProblems } from "./network.js";
|
|
|
32
32
|
import { actionProblems } from "./web-actions.js";
|
|
33
33
|
import { languageProblem } from "./language.js";
|
|
34
34
|
import { localeProblems } from "./locale.js";
|
|
35
|
-
import { repoDir } from "./gitrepo.js";
|
|
36
35
|
import { entriesNoFollow } from "./paths.js";
|
|
37
36
|
import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles } from "./scan.js";
|
|
38
37
|
/** Directories a deploy never reads out of a source tree. */
|
|
@@ -434,7 +433,7 @@ export function deployIssues(files) {
|
|
|
434
433
|
export function runsInFlight(tenant, workspace) {
|
|
435
434
|
if (!fs.existsSync(workspaceDir(tenant, workspace)))
|
|
436
435
|
return [];
|
|
437
|
-
return
|
|
436
|
+
return listRunSummaries(tenant, workspace)
|
|
438
437
|
.filter((r) => r.status === "queued" || r.status === "running" || r.status === "awaiting-approval")
|
|
439
438
|
.map((r) => r.id);
|
|
440
439
|
}
|
|
@@ -492,58 +491,14 @@ export function planDeploy(tenant, workspace, files) {
|
|
|
492
491
|
blockedBy: runsInFlight(tenant, workspace),
|
|
493
492
|
};
|
|
494
493
|
}
|
|
495
|
-
export const pushNotePath = (tenant, workspace) => path.join(repoDir(tenant, workspace), "foldrun-last-push.json");
|
|
496
|
-
export function readPushNote(tenant, workspace) {
|
|
497
|
-
try {
|
|
498
|
-
return JSON.parse(fs.readFileSync(pushNotePath(tenant, workspace), "utf8"));
|
|
499
|
-
}
|
|
500
|
-
catch {
|
|
501
|
-
return null;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
export function writePushNote(tenant, workspace, note) {
|
|
505
|
-
try {
|
|
506
|
-
fs.writeFileSync(pushNotePath(tenant, workspace), JSON.stringify(note));
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
// The note is a record, not the deploy. Losing it must not fail a push.
|
|
510
|
-
}
|
|
511
|
-
// A push waiting on a run is the scheduler's to apply; tell it there is one.
|
|
512
|
-
notifyWorkspaceChanged(tenant, workspace, "push");
|
|
513
|
-
}
|
|
514
494
|
/**
|
|
515
|
-
*
|
|
516
|
-
* failure — it is a deploy that has not happened yet. Git already said the
|
|
517
|
-
* push succeeded (the deploy happens after the client is gone), so nothing
|
|
518
|
-
* would ever tell anyone the workspace on disk is behind `main`. This applies
|
|
519
|
-
* it once the runs are done.
|
|
495
|
+
* Deploy a file list to a workspace, if it checks out.
|
|
520
496
|
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
497
|
+
* Returns the plan either way, with `applied` saying whether anything changed.
|
|
498
|
+
* A refused deploy is a normal outcome, not an exception: the caller is a
|
|
499
|
+
* webhook or a CLI that needs to report *why*, and every reason is already in
|
|
500
|
+
* the plan.
|
|
523
501
|
*/
|
|
524
|
-
export function applyPendingPush(tenant, workspace, filesAt) {
|
|
525
|
-
const note = readPushNote(tenant, workspace);
|
|
526
|
-
if (!note || note.applied)
|
|
527
|
-
return null;
|
|
528
|
-
if (!note.blockedBy?.length || (note.issues?.length ?? 0) > 0)
|
|
529
|
-
return null;
|
|
530
|
-
if (runsInFlight(tenant, workspace).length > 0)
|
|
531
|
-
return null;
|
|
532
|
-
// Only the commit the note names, and only while it is still what main
|
|
533
|
-
// points at — a newer push supersedes it and wrote its own note.
|
|
534
|
-
const result = deployWorkspace(tenant, workspace, filesAt(tenant, workspace, note.commit), {
|
|
535
|
-
commit: note.commit,
|
|
536
|
-
force: false,
|
|
537
|
-
});
|
|
538
|
-
writePushNote(tenant, workspace, {
|
|
539
|
-
...note,
|
|
540
|
-
applied: result.applied,
|
|
541
|
-
issues: result.issues ?? [],
|
|
542
|
-
blockedBy: result.blockedBy ?? [],
|
|
543
|
-
...(result.applied ? { appliedAt: new Date().toISOString() } : {}),
|
|
544
|
-
});
|
|
545
|
-
return result.applied ? { commit: note.commit } : null;
|
|
546
|
-
}
|
|
547
502
|
export function deployWorkspace(tenant, workspace, files, opts = {}) {
|
|
548
503
|
assertSafeName(tenant, "tenant");
|
|
549
504
|
assertSafeName(workspace, "workspace");
|
package/dist/src/history.d.ts
CHANGED
|
@@ -9,11 +9,6 @@
|
|
|
9
9
|
*/
|
|
10
10
|
type TreeReader = (tenant: string, scope: string) => RevisionFile[] | null;
|
|
11
11
|
export declare function registerTreeReader(fn: TreeReader): void;
|
|
12
|
-
/** Something to do after a commit lands on main — the mirror push. Registered
|
|
13
|
-
* by mirror.ts; this module cannot import it (secrets, settings). */
|
|
14
|
-
type AfterCommit = (tenant: string, scope: string, sha: string) => void;
|
|
15
|
-
export declare function registerAfterCommit(fn: AfterCommit): void;
|
|
16
|
-
export declare function notifyCommit(tenant: string, scope: string, sha: string): void;
|
|
17
12
|
export interface RevisionFile {
|
|
18
13
|
path: string;
|
|
19
14
|
/** Null when the file did not exist before. */
|
|
@@ -55,17 +50,42 @@ export declare function recordRevision(tenant: string, scope: string, files: Rev
|
|
|
55
50
|
id?: string;
|
|
56
51
|
commit?: string | null;
|
|
57
52
|
}): Revision | null;
|
|
53
|
+
/** Whether a revision can be restored to: it has a snapshot. */
|
|
54
|
+
export declare function hasSnapshot(tenant: string, scope: string, id: string): boolean;
|
|
55
|
+
/** The whole tree at revision `id`, or null when it has no snapshot. */
|
|
56
|
+
export declare function snapshotAt(tenant: string, scope: string, id: string): {
|
|
57
|
+
path: string;
|
|
58
|
+
content: string;
|
|
59
|
+
}[] | null;
|
|
60
|
+
/** The newest revision that can be restored to — what "now" is in history. */
|
|
61
|
+
export declare function headRevision(tenant: string, scope: string): RevisionSummary | null;
|
|
62
|
+
/**
|
|
63
|
+
* A scope with no snapshot yet gets one: its tree as it stands, recorded as
|
|
64
|
+
* a revision of its own — where its history starts. Every workspace that
|
|
65
|
+
* existed when history moved off git starts here, and so does one created
|
|
66
|
+
* by a path that never recorded a change. Returns the head's id, or null
|
|
67
|
+
* when there is nothing to snapshot.
|
|
68
|
+
*/
|
|
69
|
+
export declare function ensureBaseline(tenant: string, scope: string, by?: string): string | null;
|
|
70
|
+
/**
|
|
71
|
+
* The revision a restore means: an id (or the commit a deploy recorded), a
|
|
72
|
+
* date (`2026-10-17`, `2026-10-17T09:00Z`) or a span back from now (`90m`,
|
|
73
|
+
* `12h`, `3d`, `2w`) — the newest revision with a snapshot at or before that
|
|
74
|
+
* moment. Null when history holds nothing that old. Throws (status 400) on
|
|
75
|
+
* something that is none of those.
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveRevision(tenant: string, scope: string, to: string, now?: number): {
|
|
78
|
+
id: string;
|
|
79
|
+
at: string;
|
|
80
|
+
by: string;
|
|
81
|
+
message: string;
|
|
82
|
+
} | null;
|
|
58
83
|
/** Newest first. `forPath` narrows to revisions that touched one file. */
|
|
59
84
|
export declare function listRevisions(tenant: string, scope: string, opts?: {
|
|
60
85
|
forPath?: string;
|
|
61
86
|
limit?: number;
|
|
62
87
|
}): RevisionSummary[];
|
|
63
88
|
export declare function readRevision(tenant: string, scope: string, id: string, forPath?: string): Revision | null;
|
|
64
|
-
/**
|
|
65
|
-
* Make sure a scope's repository has its initial import, so a clone made
|
|
66
|
-
* right now is complete. A no-op once any commit exists.
|
|
67
|
-
*/
|
|
68
|
-
export declare function ensureImported(tenant: string, scope: string): string | null;
|
|
69
89
|
/** The most recent revision id for a scope — what a change is attributed
|
|
70
90
|
* to when nothing more specific (a deployed commit) is on record. */
|
|
71
91
|
export declare function latestRevisionId(tenant: string, scope: string): string | null;
|
package/dist/src/history.js
CHANGED
|
@@ -15,45 +15,33 @@
|
|
|
15
15
|
// git repository — and when one is connected later, its commits slot into
|
|
16
16
|
// the same series.
|
|
17
17
|
//
|
|
18
|
-
// No git
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// No git. This is a journal of content plus a snapshot of the whole tree at
|
|
19
|
+
// every revision — the platform's own deploy history, the way Vercel and
|
|
20
|
+
// Cloudflare keep theirs: rollback and restore point back to a snapshot and
|
|
21
|
+
// never need a repository. A customer's code history belongs in their own
|
|
22
|
+
// GitHub or GitLab, connected later, and is a separate thing. (Until 17 Oct
|
|
23
|
+
// 2026 this preferred a bare git repository per workspace whenever a git
|
|
24
|
+
// binary existed; that git is gone, and history started fresh from a
|
|
25
|
+
// baseline snapshot.)
|
|
22
26
|
//
|
|
23
|
-
// Storage
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
+
// Storage, under <data>/<tenant>/.history/<scope>/:
|
|
28
|
+
// <id>.json one revision: what changed, from what, to what (inline,
|
|
29
|
+
// clipped at 256 KB — what the history page diffs)
|
|
30
|
+
// index.jsonl one line per revision, newest last, so listing opens none
|
|
31
|
+
// trees/<id>.json the whole tree right after it: { path: sha256 }
|
|
32
|
+
// blobs/<aa>/<sha256> each version of a file, once — content-addressed,
|
|
33
|
+
// because a snapshot per save is mostly files that did not
|
|
34
|
+
// change, and storing them again would be the whole tree
|
|
35
|
+
// per save.
|
|
27
36
|
import fs from "node:fs";
|
|
28
37
|
import path from "node:path";
|
|
29
38
|
import crypto from "node:crypto";
|
|
30
39
|
import { dataRoot } from "./paths.js";
|
|
31
|
-
import { commitChanges, gitAvailable, headSha, listCommits, readCommit, repoExists, } from "./gitrepo.js";
|
|
32
40
|
let readTree = () => null;
|
|
33
41
|
export function registerTreeReader(fn) {
|
|
34
42
|
const prev = readTree;
|
|
35
43
|
readTree = (tenant, scope) => fn(tenant, scope) ?? prev(tenant, scope);
|
|
36
44
|
}
|
|
37
|
-
const afterCommit = [];
|
|
38
|
-
export function registerAfterCommit(fn) {
|
|
39
|
-
afterCommit.push(fn);
|
|
40
|
-
}
|
|
41
|
-
export function notifyCommit(tenant, scope, sha) {
|
|
42
|
-
for (const fn of afterCommit) {
|
|
43
|
-
try {
|
|
44
|
-
fn(tenant, scope, sha);
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
// a mirror that fails must not fail the save that triggered it
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
/** Journal or git? Git whenever the binary exists; the journal is the
|
|
52
|
-
* fallback for an install without one. An install that started on the
|
|
53
|
-
* journal keeps it readable — both are consulted on read. */
|
|
54
|
-
const useGit = () => process.env.FOLDRUN_HISTORY !== "journal" && gitAvailable();
|
|
55
|
-
// Not store's assertSafeName: store imports this module, and a cycle between
|
|
56
|
-
// the two is avoidable for one regex.
|
|
57
45
|
function assertSafeName(v, what) {
|
|
58
46
|
if (!/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(v))
|
|
59
47
|
throw new Error(`illegal ${what}: ${v}`);
|
|
@@ -82,37 +70,6 @@ export function recordRevision(tenant, scope, files, meta = {}) {
|
|
|
82
70
|
const changed = files.filter((f) => f.before !== f.after);
|
|
83
71
|
if (changed.length === 0)
|
|
84
72
|
return null;
|
|
85
|
-
if (useGit()) {
|
|
86
|
-
// First commit ever for this scope: import the whole tree as it stands
|
|
87
|
-
// now (the writer has already written), so the repository is complete
|
|
88
|
-
// from its first commit rather than starting with one file.
|
|
89
|
-
const first = !headSha(tenant, scope);
|
|
90
|
-
// Generated bundle files (knowledge/index.md, memory/log.md) are rewritten
|
|
91
|
-
// by the same save that changed a concept, outside this call. Commit them
|
|
92
|
-
// as they now stand, or a clone's index disagrees with its own files —
|
|
93
|
-
// the exact drift the template tests exist to catch.
|
|
94
|
-
const generated = first
|
|
95
|
-
? []
|
|
96
|
-
: (readTree(tenant, scope) ?? []).filter((f) => /(^|\/)(knowledge|memory)\/(.*\/)?(index|log)\.md$/.test(f.path));
|
|
97
|
-
const toCommit = first ? (readTree(tenant, scope) ?? changed) : [...changed, ...generated];
|
|
98
|
-
const sha = commitChanges(tenant, scope, toCommit, {
|
|
99
|
-
by: meta.by,
|
|
100
|
-
// Named after what the caller changed, not the generated files that
|
|
101
|
-
// rode along — "edited knowledge/style.md", not "changed 3 files".
|
|
102
|
-
message: first ? `initial import${meta.message ? ` — ${meta.message}` : ""}` : (meta.message ?? describe(changed)),
|
|
103
|
-
});
|
|
104
|
-
if (!sha)
|
|
105
|
-
return null;
|
|
106
|
-
notifyCommit(tenant, scope, sha);
|
|
107
|
-
return {
|
|
108
|
-
id: meta.commit ?? sha,
|
|
109
|
-
at: new Date().toISOString(),
|
|
110
|
-
by: meta.by ?? "system",
|
|
111
|
-
message: meta.message ?? describe(changed),
|
|
112
|
-
commit: sha,
|
|
113
|
-
files: changed,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
73
|
const dir = historyDir(tenant, scope);
|
|
117
74
|
fs.mkdirSync(dir, { recursive: true });
|
|
118
75
|
const rev = {
|
|
@@ -137,9 +94,168 @@ export function recordRevision(tenant, scope, files, meta = {}) {
|
|
|
137
94
|
commit: rev.commit,
|
|
138
95
|
paths: rev.files.map((f) => f.path),
|
|
139
96
|
};
|
|
97
|
+
// The tree as it stands now (the writer has already written), so this
|
|
98
|
+
// revision can be restored to exactly — including files a save rewrote
|
|
99
|
+
// outside this call, such as a bundle's generated index.
|
|
100
|
+
snapshot(tenant, scope, rev.id);
|
|
140
101
|
fs.appendFileSync(path.join(dir, "index.jsonl"), `${JSON.stringify(line)}\n`);
|
|
141
102
|
return rev;
|
|
142
103
|
}
|
|
104
|
+
// ----------------------------------------------------------------- snapshots
|
|
105
|
+
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
106
|
+
const treePath = (tenant, scope, id) => path.join(historyDir(tenant, scope), "trees", `${id}.json`);
|
|
107
|
+
const blobPath = (tenant, scope, sha) => path.join(historyDir(tenant, scope), "blobs", sha.slice(0, 2), sha);
|
|
108
|
+
/** Write the scope's whole tree as revision `id`'s snapshot. False when there
|
|
109
|
+
* is no tree to read (a workspace being deleted).
|
|
110
|
+
*
|
|
111
|
+
* What a snapshot is: the tree as it stood just after this revision was
|
|
112
|
+
* written — point in time, which is what a restore wants. A revision's
|
|
113
|
+
* diff names only its own change; its snapshot is everything on disk at
|
|
114
|
+
* that moment, including another writer's change that landed in between,
|
|
115
|
+
* part of one if it was mid-write (saves are not transactional across
|
|
116
|
+
* files, and never were). Reviewed with Codex, 17 Oct 2026. */
|
|
117
|
+
function snapshot(tenant, scope, id) {
|
|
118
|
+
const tree = readTree(tenant, scope);
|
|
119
|
+
if (!tree)
|
|
120
|
+
return false;
|
|
121
|
+
const files = {};
|
|
122
|
+
for (const f of tree) {
|
|
123
|
+
if (f.after === null)
|
|
124
|
+
continue;
|
|
125
|
+
const sha = sha256(f.after);
|
|
126
|
+
const blob = blobPath(tenant, scope, sha);
|
|
127
|
+
if (!fs.existsSync(blob)) {
|
|
128
|
+
fs.mkdirSync(path.dirname(blob), { recursive: true });
|
|
129
|
+
const tmp = `${blob}.${process.pid}.tmp`;
|
|
130
|
+
fs.writeFileSync(tmp, f.after);
|
|
131
|
+
fs.renameSync(tmp, blob);
|
|
132
|
+
}
|
|
133
|
+
files[f.path] = sha;
|
|
134
|
+
}
|
|
135
|
+
const file = treePath(tenant, scope, id);
|
|
136
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
137
|
+
const tmp = `${file}.tmp`;
|
|
138
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: 1, files }));
|
|
139
|
+
fs.renameSync(tmp, file);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/** Whether a revision can be restored to: it has a snapshot. */
|
|
143
|
+
export function hasSnapshot(tenant, scope, id) {
|
|
144
|
+
assertSafeName(id, "revision id");
|
|
145
|
+
return fs.existsSync(treePath(tenant, scope, id));
|
|
146
|
+
}
|
|
147
|
+
/** The whole tree at revision `id`, or null when it has no snapshot. */
|
|
148
|
+
export function snapshotAt(tenant, scope, id) {
|
|
149
|
+
assertSafeName(id, "revision id");
|
|
150
|
+
let manifest;
|
|
151
|
+
try {
|
|
152
|
+
manifest = JSON.parse(fs.readFileSync(treePath(tenant, scope, id), "utf8"));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return Object.entries(manifest.files ?? {})
|
|
158
|
+
.map(([p, sha]) => ({ path: p, content: fs.readFileSync(blobPath(tenant, scope, sha), "utf8") }))
|
|
159
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
160
|
+
}
|
|
161
|
+
/** Every revision of the scope that has a snapshot — one listing, rather
|
|
162
|
+
* than a stat per revision. */
|
|
163
|
+
function snapshotIds(tenant, scope) {
|
|
164
|
+
try {
|
|
165
|
+
return new Set(fs.readdirSync(path.join(historyDir(tenant, scope), "trees")).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return new Set();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** The newest revision that can be restored to — what "now" is in history. */
|
|
172
|
+
export function headRevision(tenant, scope) {
|
|
173
|
+
const have = snapshotIds(tenant, scope);
|
|
174
|
+
return journalRevisions(tenant, scope, { limit: Infinity }).find((r) => have.has(r.id)) ?? null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A scope with no snapshot yet gets one: its tree as it stands, recorded as
|
|
178
|
+
* a revision of its own — where its history starts. Every workspace that
|
|
179
|
+
* existed when history moved off git starts here, and so does one created
|
|
180
|
+
* by a path that never recorded a change. Returns the head's id, or null
|
|
181
|
+
* when there is nothing to snapshot.
|
|
182
|
+
*/
|
|
183
|
+
export function ensureBaseline(tenant, scope, by = "foldrun") {
|
|
184
|
+
const head = headRevision(tenant, scope);
|
|
185
|
+
if (head)
|
|
186
|
+
return head.id;
|
|
187
|
+
const tree = readTree(tenant, scope);
|
|
188
|
+
if (!tree || tree.length === 0)
|
|
189
|
+
return null;
|
|
190
|
+
const dir = historyDir(tenant, scope);
|
|
191
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
192
|
+
const id = newRevisionId();
|
|
193
|
+
// One baseline even when the web tier and the worker both find none at
|
|
194
|
+
// once: whoever creates the marker first writes it, the other reads it.
|
|
195
|
+
const marker = path.join(dir, ".baseline");
|
|
196
|
+
try {
|
|
197
|
+
fs.writeFileSync(marker, id, { flag: "wx" });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
const held = headRevision(tenant, scope)?.id ?? null;
|
|
201
|
+
// A marker with no baseline behind it, minutes old, is a process that
|
|
202
|
+
// died writing one: clear it, and the next call writes the baseline.
|
|
203
|
+
try {
|
|
204
|
+
if (!held && Date.now() - fs.statSync(marker).mtimeMs > 5 * 60_000)
|
|
205
|
+
fs.rmSync(marker, { force: true });
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// gone already
|
|
209
|
+
}
|
|
210
|
+
return held;
|
|
211
|
+
}
|
|
212
|
+
const rev = {
|
|
213
|
+
id,
|
|
214
|
+
at: new Date().toISOString(),
|
|
215
|
+
by,
|
|
216
|
+
message: "history starts here",
|
|
217
|
+
commit: null,
|
|
218
|
+
files: [],
|
|
219
|
+
};
|
|
220
|
+
fs.writeFileSync(path.join(dir, `${rev.id}.json`), JSON.stringify(rev));
|
|
221
|
+
if (!snapshot(tenant, scope, rev.id))
|
|
222
|
+
return null;
|
|
223
|
+
const line = { id: rev.id, at: rev.at, by: rev.by, message: rev.message, commit: null, paths: [] };
|
|
224
|
+
fs.appendFileSync(path.join(dir, "index.jsonl"), `${JSON.stringify(line)}\n`);
|
|
225
|
+
return rev.id;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The revision a restore means: an id (or the commit a deploy recorded), a
|
|
229
|
+
* date (`2026-10-17`, `2026-10-17T09:00Z`) or a span back from now (`90m`,
|
|
230
|
+
* `12h`, `3d`, `2w`) — the newest revision with a snapshot at or before that
|
|
231
|
+
* moment. Null when history holds nothing that old. Throws (status 400) on
|
|
232
|
+
* something that is none of those.
|
|
233
|
+
*/
|
|
234
|
+
export function resolveRevision(tenant, scope, to, now = Date.now()) {
|
|
235
|
+
const t = to.trim();
|
|
236
|
+
if (!t || t.startsWith("-"))
|
|
237
|
+
throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
238
|
+
const have = snapshotIds(tenant, scope);
|
|
239
|
+
const all = journalRevisions(tenant, scope, { limit: Infinity }).filter((r) => have.has(r.id));
|
|
240
|
+
const rel = /^(\d+)\s*(m|h|d|w)$/.exec(t);
|
|
241
|
+
const moment = rel
|
|
242
|
+
? now - Number(rel[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[rel[2]]
|
|
243
|
+
: /^\d{4}-\d{2}-\d{2}/.test(t)
|
|
244
|
+
? Date.parse(t)
|
|
245
|
+
: null;
|
|
246
|
+
let hit;
|
|
247
|
+
if (moment !== null) {
|
|
248
|
+
if (Number.isNaN(moment))
|
|
249
|
+
throw Object.assign(new Error(`not a date: ${to}`), { status: 400 });
|
|
250
|
+
hit = all.find((r) => Date.parse(r.at) <= moment);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t))
|
|
254
|
+
throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
255
|
+
hit = all.find((r) => r.id === t || r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
|
|
256
|
+
}
|
|
257
|
+
return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
|
|
258
|
+
}
|
|
143
259
|
const clip = (s) => s === null ? null : s.length > MAX_INLINE ? `[${s.length} bytes — too large to keep inline]` : s;
|
|
144
260
|
/** "edited tools/x/run.py", "created 3 files", "deleted flows/old.md". */
|
|
145
261
|
function describe(files) {
|
|
@@ -157,59 +273,76 @@ function describe(files) {
|
|
|
157
273
|
}
|
|
158
274
|
/** Newest first. `forPath` narrows to revisions that touched one file. */
|
|
159
275
|
export function listRevisions(tenant, scope, opts = {}) {
|
|
160
|
-
if (useGit() && repoExists(tenant, scope)) {
|
|
161
|
-
const fromGit = listCommits(tenant, scope, opts);
|
|
162
|
-
// Anything the journal holds from before the repository existed comes
|
|
163
|
-
// after git's rows: older, and still worth showing.
|
|
164
|
-
return [...fromGit, ...journalRevisions(tenant, scope, opts)].slice(0, opts.limit ?? 200);
|
|
165
|
-
}
|
|
166
276
|
return journalRevisions(tenant, scope, opts);
|
|
167
277
|
}
|
|
168
278
|
function journalRevisions(tenant, scope, opts = {}) {
|
|
169
|
-
const
|
|
170
|
-
|
|
279
|
+
const dir = historyDir(tenant, scope);
|
|
280
|
+
const file = path.join(dir, "index.jsonl");
|
|
281
|
+
if (!fs.existsSync(dir))
|
|
171
282
|
return [];
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
283
|
+
let all = fs.existsSync(file)
|
|
284
|
+
? fs
|
|
285
|
+
.readFileSync(file, "utf8")
|
|
286
|
+
.split("\n")
|
|
287
|
+
.filter(Boolean)
|
|
288
|
+
.flatMap((l) => {
|
|
289
|
+
try {
|
|
290
|
+
return [JSON.parse(l)];
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return []; // a torn line — the rebuild below brings it back
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
.reverse()
|
|
297
|
+
: [];
|
|
298
|
+
// The revision files are the record; index.jsonl is a cache of them. An
|
|
299
|
+
// append from two processes on NFS is not atomic across clients, and a
|
|
300
|
+
// process can die between a revision's file and its line — so when the
|
|
301
|
+
// two disagree, the index is rebuilt from the files.
|
|
302
|
+
const recorded = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).length;
|
|
303
|
+
if (recorded !== new Set(all.map((r) => r.id)).size)
|
|
304
|
+
all = rebuildIndex(dir);
|
|
305
|
+
// A line appended just after a rebuild wrote it too: once each.
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
all = all.filter((r) => !seen.has(r.id) && seen.add(r.id));
|
|
308
|
+
const narrowed = opts.forPath ? all.filter((r) => r.paths.includes(opts.forPath)) : all;
|
|
309
|
+
return narrowed.slice(0, opts.limit ?? 200);
|
|
310
|
+
}
|
|
311
|
+
/** index.jsonl again, from the revision files themselves, newest first. */
|
|
312
|
+
function rebuildIndex(dir) {
|
|
313
|
+
const rows = [];
|
|
314
|
+
for (const f of fs.readdirSync(dir)) {
|
|
315
|
+
if (!f.endsWith(".json"))
|
|
316
|
+
continue;
|
|
177
317
|
try {
|
|
178
|
-
|
|
318
|
+
const file = path.join(dir, f);
|
|
319
|
+
const rev = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
320
|
+
rows.push({ id: rev.id, at: rev.at, by: rev.by, message: rev.message, commit: rev.commit, paths: rev.files.map((x) => x.path), written: fs.statSync(file).mtimeMs });
|
|
179
321
|
}
|
|
180
322
|
catch {
|
|
181
|
-
|
|
323
|
+
// a revision half-written by a process that died: not one
|
|
182
324
|
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
325
|
+
}
|
|
326
|
+
// Two saves in one millisecond share an `at`; the order they were written
|
|
327
|
+
// in breaks the tie (an id's suffix is random, so it cannot).
|
|
328
|
+
rows.sort((a, b) => a.at.localeCompare(b.at) || a.written - b.written || a.id.localeCompare(b.id));
|
|
329
|
+
try {
|
|
330
|
+
const tmp = path.join(dir, `index.jsonl.${process.pid}.tmp`);
|
|
331
|
+
fs.writeFileSync(tmp, rows.map(({ written: _w, ...r }) => JSON.stringify(r) + "\n").join(""));
|
|
332
|
+
fs.renameSync(tmp, path.join(dir, "index.jsonl"));
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// a read-only volume still gets the answer
|
|
336
|
+
}
|
|
337
|
+
return rows.map(({ written: _w, ...r }) => r).reverse();
|
|
187
338
|
}
|
|
188
339
|
export function readRevision(tenant, scope, id, forPath) {
|
|
189
340
|
assertSafeName(id, "revision id");
|
|
190
|
-
if (useGit() && repoExists(tenant, scope) && /^[0-9a-f]{7,40}$/.test(id)) {
|
|
191
|
-
const fromGit = readCommit(tenant, scope, id, forPath);
|
|
192
|
-
if (fromGit)
|
|
193
|
-
return fromGit;
|
|
194
|
-
}
|
|
195
341
|
const file = path.join(historyDir(tenant, scope), `${id}.json`);
|
|
196
342
|
if (!fs.existsSync(file))
|
|
197
343
|
return null;
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Make sure a scope's repository has its initial import, so a clone made
|
|
202
|
-
* right now is complete. A no-op once any commit exists.
|
|
203
|
-
*/
|
|
204
|
-
export function ensureImported(tenant, scope) {
|
|
205
|
-
if (!useGit())
|
|
206
|
-
return null;
|
|
207
|
-
if (headSha(tenant, scope))
|
|
208
|
-
return headSha(tenant, scope);
|
|
209
|
-
const tree = readTree(tenant, scope);
|
|
210
|
-
if (!tree || tree.length === 0)
|
|
211
|
-
return null;
|
|
212
|
-
return commitChanges(tenant, scope, tree, { by: "foldrun", message: "initial import" });
|
|
344
|
+
const rev = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
345
|
+
return forPath ? { ...rev, files: rev.files.filter((f) => f.path === forPath) } : rev;
|
|
213
346
|
}
|
|
214
347
|
/** The most recent revision id for a scope — what a change is attributed
|
|
215
348
|
* to when nothing more specific (a deployed commit) is on record. */
|
package/dist/src/library.d.ts
CHANGED
|
@@ -88,16 +88,3 @@ export interface LibraryUse {
|
|
|
88
88
|
relation: "use" | "shadowed";
|
|
89
89
|
}
|
|
90
90
|
export declare function libraryUsage(tenant: string, kind: LibraryKind, name: string): LibraryUse[];
|
|
91
|
-
/**
|
|
92
|
-
* Make the shelf match a tree — what a push to the library repository does.
|
|
93
|
-
* Written with plain fs and recorded as ONE revision at the end: going
|
|
94
|
-
* through writeLibraryFile per file would commit each one on top of the
|
|
95
|
-
* pushed commit, and the history would show a push as fifty edits.
|
|
96
|
-
*/
|
|
97
|
-
export declare function syncLibraryFromTree(tenant: string, files: {
|
|
98
|
-
path: string;
|
|
99
|
-
content: string;
|
|
100
|
-
}[], meta?: {
|
|
101
|
-
by?: string;
|
|
102
|
-
commit?: string | null;
|
|
103
|
-
}): number;
|