@letta-ai/letta-code 0.30.28 → 0.30.29
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/mcp-client.js +2 -2
- package/dist/mcp-client.js.map +1 -1
- package/dist/types/agent/client-skills.d.ts +3 -0
- package/dist/types/agent/client-skills.d.ts.map +1 -1
- package/dist/types/agent/memory-git.d.ts +2 -0
- package/dist/types/agent/memory-git.d.ts.map +1 -1
- package/dist/types/agent/shared-memory-skills.d.ts +18 -0
- package/dist/types/agent/shared-memory-skills.d.ts.map +1 -0
- package/dist/types/backend/dev/pi-model-factory.d.ts +6 -0
- package/dist/types/backend/dev/pi-model-factory.d.ts.map +1 -1
- package/dist/types/backend/dev/pi-provider-registry.d.ts.map +1 -1
- package/dist/types/backend/local/local-provider-auth-store.d.ts.map +1 -1
- package/dist/types/tools/impl/skill.d.ts +10 -5
- package/dist/types/tools/impl/skill.d.ts.map +1 -1
- package/image-resize-worker.js +42 -20
- package/letta.js +87528 -141365
- package/package.json +4 -3
- package/scripts/codex-watch/agent-watch.ts +63 -8
- package/scripts/codex-watch/release-analysis.test.ts +57 -0
- package/scripts/codex-watch/release-analysis.ts +31 -1
- package/scripts/codex-watch/tracker.test.ts +243 -6
- package/scripts/codex-watch/tracker.ts +221 -20
- package/scripts/pi-ai-watch/agent-watch.ts +253 -0
- package/scripts/pi-ai-watch/github.ts +145 -0
- package/scripts/pi-ai-watch/release-analysis.test.ts +137 -0
- package/scripts/pi-ai-watch/release-analysis.ts +371 -0
- package/scripts/pi-ai-watch/tracker.test.ts +138 -0
- package/scripts/pi-ai-watch/tracker.ts +303 -0
- package/scripts/pi-ai-watch/update-tracker.ts +215 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
areAdjacentStableReleases,
|
|
7
|
+
compareStableVersions,
|
|
8
|
+
extractChangelogSection,
|
|
9
|
+
findNextStableRelease,
|
|
10
|
+
type PackageRelease,
|
|
11
|
+
parseRegistryMetadata,
|
|
12
|
+
readInstalledVersion,
|
|
13
|
+
} from "./release-analysis.ts";
|
|
14
|
+
|
|
15
|
+
const temporaryDirectories: string[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const directory of temporaryDirectories.splice(0)) {
|
|
19
|
+
rmSync(directory, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("pi-ai npm releases", () => {
|
|
24
|
+
test("filters prereleases and orders stable versions semantically", () => {
|
|
25
|
+
const releases = parseRegistryMetadata({
|
|
26
|
+
versions: {
|
|
27
|
+
"0.10.0": {
|
|
28
|
+
version: "0.10.0",
|
|
29
|
+
gitHead: "new",
|
|
30
|
+
dist: { integrity: "sha-new", tarball: "https://example/new.tgz" },
|
|
31
|
+
},
|
|
32
|
+
"0.9.1-beta.0": { version: "0.9.1-beta.0" },
|
|
33
|
+
"0.9.2": { version: "0.9.2" },
|
|
34
|
+
},
|
|
35
|
+
time: {
|
|
36
|
+
"0.10.0": "2026-01-03T00:00:00Z",
|
|
37
|
+
"0.9.1-beta.0": "2026-01-01T00:00:00Z",
|
|
38
|
+
"0.9.2": "2026-01-02T00:00:00Z",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(releases.map((release) => release.version)).toEqual([
|
|
43
|
+
"0.9.2",
|
|
44
|
+
"0.10.0",
|
|
45
|
+
]);
|
|
46
|
+
expect(releases[1]).toMatchObject({
|
|
47
|
+
integrity: "sha-new",
|
|
48
|
+
tarball_url: "https://example/new.tgz",
|
|
49
|
+
git_head: "new",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("rejects stable releases without publication times", () => {
|
|
54
|
+
expect(() =>
|
|
55
|
+
parseRegistryMetadata({
|
|
56
|
+
versions: { "0.82.1": { version: "0.82.1" } },
|
|
57
|
+
time: {},
|
|
58
|
+
}),
|
|
59
|
+
).toThrow("missing publication time for 0.82.1");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("selects exactly the next stable version after the cursor", () => {
|
|
63
|
+
const releases = releaseList("0.82.1", "0.83.0", "0.84.0");
|
|
64
|
+
expect(findNextStableRelease(releases, "0.82.1")?.version).toBe("0.83.0");
|
|
65
|
+
expect(findNextStableRelease(releases, "0.84.0")).toBeNull();
|
|
66
|
+
expect(() => findNextStableRelease(releases, "0.80.0")).toThrow(
|
|
67
|
+
"Could not find pi-ai cursor release 0.80.0",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("recognizes only adjacent release pairs", () => {
|
|
72
|
+
const releases = releaseList("0.82.1", "0.83.0", "0.84.0");
|
|
73
|
+
expect(areAdjacentStableReleases(releases, "0.82.1", "0.83.0")).toBe(true);
|
|
74
|
+
expect(areAdjacentStableReleases(releases, "0.82.1", "0.84.0")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("pi-ai release evidence", () => {
|
|
79
|
+
test("extracts only the requested changelog section", () => {
|
|
80
|
+
const changelog = `# Changelog
|
|
81
|
+
|
|
82
|
+
## [0.84.0] - 2026-08-06
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- Current feature.
|
|
87
|
+
|
|
88
|
+
## [0.83.0] - 2026-07-29
|
|
89
|
+
|
|
90
|
+
- Previous feature.
|
|
91
|
+
`;
|
|
92
|
+
expect(
|
|
93
|
+
extractChangelogSection(changelog, "0.84.0"),
|
|
94
|
+
).toBe(`## [0.84.0] - 2026-08-06
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- Current feature.`);
|
|
99
|
+
expect(() => extractChangelogSection(changelog, "0.82.1")).toThrow(
|
|
100
|
+
"Could not find pi-ai changelog section 0.82.1",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("reads the exact resolved version from bun.lock", () => {
|
|
105
|
+
const directory = mkdtempSync(join(tmpdir(), "pi-ai-watch-test-"));
|
|
106
|
+
temporaryDirectories.push(directory);
|
|
107
|
+
const lockfile = join(directory, "bun.lock");
|
|
108
|
+
writeFileSync(
|
|
109
|
+
lockfile,
|
|
110
|
+
`{
|
|
111
|
+
"workspaces": {
|
|
112
|
+
"": { "dependencies": { "@earendil-works/pi-ai": "^0.82.0" } }
|
|
113
|
+
},
|
|
114
|
+
"packages": {
|
|
115
|
+
"@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.82.1", ""]
|
|
116
|
+
}
|
|
117
|
+
}`,
|
|
118
|
+
);
|
|
119
|
+
expect(readInstalledVersion(lockfile)).toBe("0.82.1");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("compares multi-digit semantic version components", () => {
|
|
123
|
+
expect(compareStableVersions("0.9.9", "0.10.0")).toBeLessThan(0);
|
|
124
|
+
expect(compareStableVersions("1.0.0", "0.99.99")).toBeGreaterThan(0);
|
|
125
|
+
expect(compareStableVersions("0.84.2", "0.84.2")).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function releaseList(...versions: string[]): PackageRelease[] {
|
|
130
|
+
return versions.map((version, index) => ({
|
|
131
|
+
version,
|
|
132
|
+
published_at: `2026-08-${String(index + 1).padStart(2, "0")}T00:00:00Z`,
|
|
133
|
+
integrity: null,
|
|
134
|
+
tarball_url: null,
|
|
135
|
+
git_head: null,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const PI_AI_PACKAGE = "@earendil-works/pi-ai";
|
|
7
|
+
export const PI_AI_REPO = "earendil-works/pi";
|
|
8
|
+
export const DEFAULT_TARGET_REPO =
|
|
9
|
+
process.env.GITHUB_REPOSITORY || "letta-ai/letta-code";
|
|
10
|
+
|
|
11
|
+
interface RegistryVersion {
|
|
12
|
+
version: string;
|
|
13
|
+
gitHead?: string;
|
|
14
|
+
dist?: {
|
|
15
|
+
integrity?: string;
|
|
16
|
+
tarball?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface RegistryMetadata {
|
|
21
|
+
versions?: Record<string, RegistryVersion>;
|
|
22
|
+
time?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PackageRelease {
|
|
26
|
+
version: string;
|
|
27
|
+
published_at: string;
|
|
28
|
+
integrity: string | null;
|
|
29
|
+
tarball_url: string | null;
|
|
30
|
+
git_head: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AnalyzePiAiReleaseOptions {
|
|
34
|
+
previousVersion: string | null;
|
|
35
|
+
currentVersion: string | null;
|
|
36
|
+
installedVersion?: string;
|
|
37
|
+
stableReleases?: PackageRelease[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PiAiWatchAnalysis {
|
|
41
|
+
package: typeof PI_AI_PACKAGE;
|
|
42
|
+
installed_version: string;
|
|
43
|
+
previous_version: string;
|
|
44
|
+
current_version: string;
|
|
45
|
+
is_adjacent_release: boolean;
|
|
46
|
+
published_at: string;
|
|
47
|
+
integrity: string | null;
|
|
48
|
+
tarball_url: string | null;
|
|
49
|
+
git_head: string | null;
|
|
50
|
+
release_url: string;
|
|
51
|
+
compare_url: string;
|
|
52
|
+
changelog_md: string;
|
|
53
|
+
changed_files: string[];
|
|
54
|
+
diff_stat: string;
|
|
55
|
+
package_json_diff: string | null;
|
|
56
|
+
workflow_run_url: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function analyzePiAiRelease(
|
|
60
|
+
options: AnalyzePiAiReleaseOptions,
|
|
61
|
+
): Promise<PiAiWatchAnalysis> {
|
|
62
|
+
const releases = options.stableReleases ?? (await listStableReleases());
|
|
63
|
+
if (releases.length === 0) throw new Error("No stable pi-ai releases found");
|
|
64
|
+
|
|
65
|
+
const current = options.currentVersion
|
|
66
|
+
? releases.find((release) => release.version === options.currentVersion)
|
|
67
|
+
: releases.at(-1);
|
|
68
|
+
if (!current) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Could not find current pi-ai release ${options.currentVersion}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const previous = options.previousVersion
|
|
75
|
+
? releases.find((release) => release.version === options.previousVersion)
|
|
76
|
+
: findPreviousStableRelease(releases, current.version);
|
|
77
|
+
if (!previous) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Could not find previous pi-ai release before ${current.version}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const installedVersion = options.installedVersion ?? readInstalledVersion();
|
|
84
|
+
const previousTag = `v${previous.version}`;
|
|
85
|
+
const currentTag = `v${current.version}`;
|
|
86
|
+
const temp = mkdtempSync(join(tmpdir(), "pi-ai-watch-"));
|
|
87
|
+
try {
|
|
88
|
+
const repoDir = clonePi(temp);
|
|
89
|
+
fetchTag(repoDir, previousTag);
|
|
90
|
+
fetchTag(repoDir, currentTag);
|
|
91
|
+
verifyTagMatchesPackage(repoDir, previousTag, previous);
|
|
92
|
+
verifyTagMatchesPackage(repoDir, currentTag, current);
|
|
93
|
+
const changelog = showFile(repoDir, currentTag, "packages/ai/CHANGELOG.md");
|
|
94
|
+
if (changelog === null) {
|
|
95
|
+
throw new Error(`packages/ai/CHANGELOG.md is missing at ${currentTag}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
package: PI_AI_PACKAGE,
|
|
100
|
+
installed_version: installedVersion,
|
|
101
|
+
previous_version: previous.version,
|
|
102
|
+
current_version: current.version,
|
|
103
|
+
is_adjacent_release: areAdjacentStableReleases(
|
|
104
|
+
releases,
|
|
105
|
+
previous.version,
|
|
106
|
+
current.version,
|
|
107
|
+
),
|
|
108
|
+
published_at: current.published_at,
|
|
109
|
+
integrity: current.integrity,
|
|
110
|
+
tarball_url: current.tarball_url,
|
|
111
|
+
git_head: current.git_head,
|
|
112
|
+
release_url: `https://github.com/${PI_AI_REPO}/releases/tag/${currentTag}`,
|
|
113
|
+
compare_url: `https://github.com/${PI_AI_REPO}/compare/${previousTag}...${currentTag}`,
|
|
114
|
+
changelog_md: extractChangelogSection(changelog, current.version),
|
|
115
|
+
changed_files: changedFiles(repoDir, previousTag, currentTag),
|
|
116
|
+
diff_stat: diffStat(repoDir, previousTag, currentTag),
|
|
117
|
+
package_json_diff: diffPreview(
|
|
118
|
+
repoDir,
|
|
119
|
+
previousTag,
|
|
120
|
+
currentTag,
|
|
121
|
+
"packages/ai/package.json",
|
|
122
|
+
),
|
|
123
|
+
workflow_run_url: workflowRunUrl(),
|
|
124
|
+
};
|
|
125
|
+
} finally {
|
|
126
|
+
rmSync(temp, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function listStableReleases(): Promise<PackageRelease[]> {
|
|
131
|
+
const response = await fetch(
|
|
132
|
+
"https://registry.npmjs.org/@earendil-works%2Fpi-ai",
|
|
133
|
+
);
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`npm registry request failed (${response.status}): ${await response.text()}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return parseRegistryMetadata((await response.json()) as RegistryMetadata);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseRegistryMetadata(
|
|
143
|
+
metadata: RegistryMetadata,
|
|
144
|
+
): PackageRelease[] {
|
|
145
|
+
if (!metadata.versions || !metadata.time) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"pi-ai npm metadata is missing versions or publication times",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Object.entries(metadata.versions)
|
|
152
|
+
.filter(([version]) => isStableVersion(version))
|
|
153
|
+
.map(([version, details]) => {
|
|
154
|
+
const publishedAt = metadata.time?.[version];
|
|
155
|
+
if (!publishedAt) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`pi-ai npm metadata is missing publication time for ${version}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
version,
|
|
162
|
+
published_at: publishedAt,
|
|
163
|
+
integrity: details.dist?.integrity ?? null,
|
|
164
|
+
tarball_url: details.dist?.tarball ?? null,
|
|
165
|
+
git_head: details.gitHead ?? null,
|
|
166
|
+
};
|
|
167
|
+
})
|
|
168
|
+
.sort((left, right) => compareStableVersions(left.version, right.version));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function readInstalledVersion(lockfilePath = "bun.lock"): string {
|
|
172
|
+
const lockfile = readFileSync(lockfilePath, "utf8");
|
|
173
|
+
const escapedPackage = escapeRegExp(PI_AI_PACKAGE);
|
|
174
|
+
const match = new RegExp(
|
|
175
|
+
`"${escapedPackage}": \\["${escapedPackage}@(\\d+\\.\\d+\\.\\d+)"`,
|
|
176
|
+
).exec(lockfile);
|
|
177
|
+
if (!match?.[1]) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Could not parse resolved ${PI_AI_PACKAGE} version from ${lockfilePath}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return match[1];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function findNextStableRelease(
|
|
186
|
+
releases: PackageRelease[],
|
|
187
|
+
cursorVersion: string,
|
|
188
|
+
): PackageRelease | null {
|
|
189
|
+
const index = releases.findIndex(
|
|
190
|
+
(release) => release.version === cursorVersion,
|
|
191
|
+
);
|
|
192
|
+
if (index < 0) {
|
|
193
|
+
throw new Error(`Could not find pi-ai cursor release ${cursorVersion}`);
|
|
194
|
+
}
|
|
195
|
+
return releases[index + 1] ?? null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function areAdjacentStableReleases(
|
|
199
|
+
releases: PackageRelease[],
|
|
200
|
+
previousVersion: string,
|
|
201
|
+
currentVersion: string,
|
|
202
|
+
): boolean {
|
|
203
|
+
return (
|
|
204
|
+
findPreviousStableRelease(releases, currentVersion)?.version ===
|
|
205
|
+
previousVersion
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function extractChangelogSection(
|
|
210
|
+
changelog: string,
|
|
211
|
+
version: string,
|
|
212
|
+
): string {
|
|
213
|
+
const heading = new RegExp(`^## \\[${escapeRegExp(version)}\\].*$`, "m");
|
|
214
|
+
const match = heading.exec(changelog);
|
|
215
|
+
if (!match)
|
|
216
|
+
throw new Error(`Could not find pi-ai changelog section ${version}`);
|
|
217
|
+
const start = match.index;
|
|
218
|
+
const remaining = changelog.slice(start + match[0].length);
|
|
219
|
+
const next = /^## \[/m.exec(remaining);
|
|
220
|
+
const end = next ? start + match[0].length + next.index : changelog.length;
|
|
221
|
+
return changelog.slice(start, end).trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function findPreviousStableRelease(
|
|
225
|
+
releases: PackageRelease[],
|
|
226
|
+
currentVersion: string,
|
|
227
|
+
): PackageRelease | null {
|
|
228
|
+
const index = releases.findIndex(
|
|
229
|
+
(release) => release.version === currentVersion,
|
|
230
|
+
);
|
|
231
|
+
if (index <= 0) return null;
|
|
232
|
+
return releases[index - 1] ?? null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function compareStableVersions(left: string, right: string): number {
|
|
236
|
+
const leftParts = parseVersion(left);
|
|
237
|
+
const rightParts = parseVersion(right);
|
|
238
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
239
|
+
const difference = leftParts[index]! - rightParts[index]!;
|
|
240
|
+
if (difference !== 0) return difference;
|
|
241
|
+
}
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseVersion(version: string): [number, number, number] {
|
|
246
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
247
|
+
if (!match) throw new Error(`Invalid stable pi-ai version ${version}`);
|
|
248
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isStableVersion(version: string): boolean {
|
|
252
|
+
return /^\d+\.\d+\.\d+$/.test(version);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function clonePi(temp: string): string {
|
|
256
|
+
const directory = join(temp, "pi");
|
|
257
|
+
git([
|
|
258
|
+
"clone",
|
|
259
|
+
"--filter=blob:none",
|
|
260
|
+
"--no-checkout",
|
|
261
|
+
`https://github.com/${PI_AI_REPO}.git`,
|
|
262
|
+
directory,
|
|
263
|
+
]);
|
|
264
|
+
return directory;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function fetchTag(repoDir: string, tag: string): void {
|
|
268
|
+
git(
|
|
269
|
+
[
|
|
270
|
+
"fetch",
|
|
271
|
+
"--filter=blob:none",
|
|
272
|
+
"origin",
|
|
273
|
+
`refs/tags/${tag}:refs/tags/${tag}`,
|
|
274
|
+
],
|
|
275
|
+
repoDir,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function verifyTagMatchesPackage(
|
|
280
|
+
repoDir: string,
|
|
281
|
+
tag: string,
|
|
282
|
+
release: PackageRelease,
|
|
283
|
+
): void {
|
|
284
|
+
if (!release.git_head) return;
|
|
285
|
+
const tagCommit = git(["rev-list", "-n", "1", tag], repoDir).trim();
|
|
286
|
+
if (tagCommit !== release.git_head) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`pi-ai npm ${release.version} gitHead ${release.git_head} does not match ${tag} commit ${tagCommit}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function showFile(repoDir: string, tag: string, path: string): string | null {
|
|
294
|
+
const result = spawnSync("git", ["show", `${tag}:${path}`], {
|
|
295
|
+
cwd: repoDir,
|
|
296
|
+
encoding: "utf8",
|
|
297
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
298
|
+
});
|
|
299
|
+
return result.status === 0 ? result.stdout : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function changedFiles(
|
|
303
|
+
repoDir: string,
|
|
304
|
+
previousTag: string,
|
|
305
|
+
currentTag: string,
|
|
306
|
+
): string[] {
|
|
307
|
+
return git(
|
|
308
|
+
[
|
|
309
|
+
"diff",
|
|
310
|
+
"--name-only",
|
|
311
|
+
`${previousTag}..${currentTag}`,
|
|
312
|
+
"--",
|
|
313
|
+
"packages/ai",
|
|
314
|
+
],
|
|
315
|
+
repoDir,
|
|
316
|
+
)
|
|
317
|
+
.split("\n")
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function diffStat(
|
|
322
|
+
repoDir: string,
|
|
323
|
+
previousTag: string,
|
|
324
|
+
currentTag: string,
|
|
325
|
+
): string {
|
|
326
|
+
return git(
|
|
327
|
+
["diff", "--stat", `${previousTag}..${currentTag}`, "--", "packages/ai"],
|
|
328
|
+
repoDir,
|
|
329
|
+
).trim();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function diffPreview(
|
|
333
|
+
repoDir: string,
|
|
334
|
+
previousTag: string,
|
|
335
|
+
currentTag: string,
|
|
336
|
+
path: string,
|
|
337
|
+
): string | null {
|
|
338
|
+
const output = git(
|
|
339
|
+
["diff", "--unified=3", `${previousTag}..${currentTag}`, "--", path],
|
|
340
|
+
repoDir,
|
|
341
|
+
);
|
|
342
|
+
return output.trim() ? output.split("\n").slice(0, 160).join("\n") : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function git(args: string[], cwd?: string): string {
|
|
346
|
+
const result = spawnSync("git", args, {
|
|
347
|
+
cwd,
|
|
348
|
+
encoding: "utf8",
|
|
349
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
350
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
351
|
+
});
|
|
352
|
+
if (result.status !== 0) {
|
|
353
|
+
throw new Error(`git ${args.join(" ")} failed:\n${result.stderr}`);
|
|
354
|
+
}
|
|
355
|
+
return result.stdout;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function workflowRunUrl(): string {
|
|
359
|
+
if (
|
|
360
|
+
process.env.GITHUB_SERVER_URL &&
|
|
361
|
+
process.env.GITHUB_REPOSITORY &&
|
|
362
|
+
process.env.GITHUB_RUN_ID
|
|
363
|
+
) {
|
|
364
|
+
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
365
|
+
}
|
|
366
|
+
return "local dry-run";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function escapeRegExp(value: string): string {
|
|
370
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
371
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { PiAiWatchAnalysis } from "./release-analysis.ts";
|
|
3
|
+
import {
|
|
4
|
+
advanceMergedPr,
|
|
5
|
+
getPendingPrForCursor,
|
|
6
|
+
hasCompletedRange,
|
|
7
|
+
hasRecordedOutcome,
|
|
8
|
+
initialTrackerState,
|
|
9
|
+
parseTrackerState,
|
|
10
|
+
recordAnalysis,
|
|
11
|
+
renderTrackerBody,
|
|
12
|
+
} from "./tracker.ts";
|
|
13
|
+
|
|
14
|
+
describe("pi-ai watch tracker", () => {
|
|
15
|
+
test("round trips the initial installed-version cursor", () => {
|
|
16
|
+
const state = initialTrackerState("0.82.1");
|
|
17
|
+
expect(parseTrackerState(renderTrackerBody(state))).toEqual(state);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("advances after a no-upgrade decision", () => {
|
|
21
|
+
const state = recordAnalysis(initialTrackerState("0.82.1"), {
|
|
22
|
+
analysis: analysis("0.82.1", "0.83.0"),
|
|
23
|
+
outcome: "no_upgrade",
|
|
24
|
+
notes: "upstream-only provider change",
|
|
25
|
+
processedAt: "2026-08-21T00:00:00Z",
|
|
26
|
+
});
|
|
27
|
+
expect(state.audit_cursor_version).toBe("0.83.0");
|
|
28
|
+
expect(hasCompletedRange(state, "0.82.1", "0.83.0")).toBe(true);
|
|
29
|
+
expect(hasRecordedOutcome(state, "0.82.1", "0.83.0")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("keeps a created PR pending until it merges", () => {
|
|
33
|
+
const pending = recordAnalysis(initialTrackerState("0.82.1"), {
|
|
34
|
+
analysis: analysis("0.82.1", "0.83.0"),
|
|
35
|
+
outcome: "pr_created",
|
|
36
|
+
notes: "upgrade",
|
|
37
|
+
prUrl: "https://github.com/letta-ai/letta-code/pull/123",
|
|
38
|
+
processedAt: "2026-08-21T00:00:00Z",
|
|
39
|
+
});
|
|
40
|
+
expect(pending.audit_cursor_version).toBe("0.82.1");
|
|
41
|
+
expect(getPendingPrForCursor(pending)?.version).toBe("0.83.0");
|
|
42
|
+
expect(hasCompletedRange(pending, "0.82.1", "0.83.0")).toBe(false);
|
|
43
|
+
expect(hasRecordedOutcome(pending, "0.82.1", "0.83.0")).toBe(true);
|
|
44
|
+
|
|
45
|
+
const merged = advanceMergedPr(pending, "0.83.0");
|
|
46
|
+
expect(merged.audit_cursor_version).toBe("0.83.0");
|
|
47
|
+
expect(getPendingPrForCursor(merged)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("keeps errors retryable and replaces them with a later outcome", () => {
|
|
51
|
+
const failed = recordAnalysis(initialTrackerState("0.82.1"), {
|
|
52
|
+
analysis: analysis("0.82.1", "0.83.0"),
|
|
53
|
+
outcome: "error",
|
|
54
|
+
notes: "agent failed",
|
|
55
|
+
processedAt: "2026-08-21T00:00:00Z",
|
|
56
|
+
});
|
|
57
|
+
expect(failed.audit_cursor_version).toBe("0.82.1");
|
|
58
|
+
expect(hasRecordedOutcome(failed, "0.82.1", "0.83.0")).toBe(false);
|
|
59
|
+
|
|
60
|
+
const retried = recordAnalysis(failed, {
|
|
61
|
+
analysis: analysis("0.82.1", "0.83.0"),
|
|
62
|
+
outcome: "no_upgrade",
|
|
63
|
+
notes: "reviewed on retry",
|
|
64
|
+
processedAt: "2026-08-21T01:00:00Z",
|
|
65
|
+
});
|
|
66
|
+
expect(retried.audit_cursor_version).toBe("0.83.0");
|
|
67
|
+
expect(retried.processed).toHaveLength(1);
|
|
68
|
+
expect(retried.processed[0]?.outcome).toBe("no_upgrade");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("advances uncertain releases but not explicit non-adjacent replays", () => {
|
|
72
|
+
const human = recordAnalysis(initialTrackerState("0.82.1"), {
|
|
73
|
+
analysis: analysis("0.82.1", "0.83.0"),
|
|
74
|
+
outcome: "needs_human_review",
|
|
75
|
+
notes: "product decision",
|
|
76
|
+
});
|
|
77
|
+
expect(human.audit_cursor_version).toBe("0.83.0");
|
|
78
|
+
|
|
79
|
+
const replay = recordAnalysis(initialTrackerState("0.82.1"), {
|
|
80
|
+
analysis: analysis("0.82.1", "0.84.0", false),
|
|
81
|
+
outcome: "no_upgrade",
|
|
82
|
+
notes: "explicit replay",
|
|
83
|
+
});
|
|
84
|
+
expect(replay.audit_cursor_version).toBe("0.82.1");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects malformed hidden state", () => {
|
|
88
|
+
expect(() => parseTrackerState("missing")).toThrow(
|
|
89
|
+
"pi-ai tracker hidden state is missing",
|
|
90
|
+
);
|
|
91
|
+
expect(() =>
|
|
92
|
+
parseTrackerState(`<!-- pi-ai-watch-state
|
|
93
|
+
{"audit_cursor_version":"latest","last_checked_version":null,"last_checked_at":null,"processed":[]}
|
|
94
|
+
-->`),
|
|
95
|
+
).toThrow("pi-ai tracker hidden state is invalid");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("bounds tracker history", () => {
|
|
99
|
+
let state = initialTrackerState("0.1.0");
|
|
100
|
+
for (let index = 1; index <= 60; index += 1) {
|
|
101
|
+
const previous = `0.${index}.0`;
|
|
102
|
+
const current = `0.${index}.1`;
|
|
103
|
+
state = recordAnalysis(state, {
|
|
104
|
+
analysis: analysis(previous, current, false),
|
|
105
|
+
outcome: "error",
|
|
106
|
+
notes: `attempt ${index}`,
|
|
107
|
+
processedAt: `2026-08-21T00:${String(index).padStart(2, "0")}:00Z`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
expect(state.processed).toHaveLength(50);
|
|
111
|
+
expect(state.processed[0]?.version).toBe("0.60.1");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function analysis(
|
|
116
|
+
previousVersion: string,
|
|
117
|
+
currentVersion: string,
|
|
118
|
+
adjacent = true,
|
|
119
|
+
): PiAiWatchAnalysis {
|
|
120
|
+
return {
|
|
121
|
+
package: "@earendil-works/pi-ai",
|
|
122
|
+
installed_version: "0.82.1",
|
|
123
|
+
previous_version: previousVersion,
|
|
124
|
+
current_version: currentVersion,
|
|
125
|
+
is_adjacent_release: adjacent,
|
|
126
|
+
published_at: "2026-08-14T00:00:00Z",
|
|
127
|
+
integrity: "sha512-test",
|
|
128
|
+
tarball_url: "https://example.test/pi-ai.tgz",
|
|
129
|
+
git_head: "abc123",
|
|
130
|
+
release_url: `https://github.com/earendil-works/pi/releases/tag/v${currentVersion}`,
|
|
131
|
+
compare_url: `https://github.com/earendil-works/pi/compare/v${previousVersion}...v${currentVersion}`,
|
|
132
|
+
changelog_md: "## Added",
|
|
133
|
+
changed_files: ["packages/ai/src/index.ts"],
|
|
134
|
+
diff_stat: "1 file changed",
|
|
135
|
+
package_json_diff: null,
|
|
136
|
+
workflow_run_url: "https://github.com/letta-ai/letta-code/actions/runs/1",
|
|
137
|
+
};
|
|
138
|
+
}
|