@functional-systems/srcx 0.1.0-dev.4 → 0.1.0-dev.6
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/CHANGELOG.md +32 -0
- package/DESIGN.md +47 -13
- package/README.md +68 -7
- package/VALIDATION.md +109 -10
- package/dist/build.d.ts +4 -1
- package/dist/build.js +4 -2
- package/dist/build.js.map +1 -1
- package/dist/chunk.d.ts +2 -1
- package/dist/chunk.js +10 -9
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +37 -13
- package/dist/cli.js.map +1 -1
- package/dist/publish.d.ts +18 -0
- package/dist/publish.js +143 -14
- package/dist/publish.js.map +1 -1
- package/dist/releases.js +21 -3
- package/dist/releases.js.map +1 -1
- package/package.json +8 -5
- package/src/build.ts +13 -2
- package/src/chunk.ts +12 -7
- package/src/cli.ts +46 -12
- package/src/publish.ts +204 -14
- package/src/releases.ts +36 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@functional-systems/srcx",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Version-aware code search on LambdaDB",
|
|
6
6
|
"type": "module",
|
|
@@ -28,11 +28,14 @@
|
|
|
28
28
|
"start": "node dist/cli.js",
|
|
29
29
|
"demo": "npm run build && node scripts/demo.mjs",
|
|
30
30
|
"prepack": "npm run build",
|
|
31
|
-
"format": "prettier --write src test scripts README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
|
|
32
|
-
"format:check": "prettier --check src test scripts README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
|
|
31
|
+
"format": "prettier --write src test scripts eval README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
|
|
32
|
+
"format:check": "prettier --check src test scripts eval README.md VALIDATION.md CONTRIBUTING.md RELEASING.md CHANGELOG.md .github package.json tsconfig.json",
|
|
33
33
|
"test:live": "npm run build && node --env-file=.env.local scripts/live.mjs",
|
|
34
34
|
"check:version": "node scripts/check-release.mjs",
|
|
35
|
-
"test:package": "node scripts/test-package.mjs"
|
|
35
|
+
"test:package": "node scripts/test-package.mjs",
|
|
36
|
+
"test:live:branches": "npm run build && node --env-file=.env.local scripts/live-branches.mjs",
|
|
37
|
+
"eval:prepare": "npm run build && node scripts/retrieval-eval.mjs prepare",
|
|
38
|
+
"eval:run": "node scripts/retrieval-eval.mjs run"
|
|
36
39
|
},
|
|
37
40
|
"dependencies": {
|
|
38
41
|
"@functional-systems/lambdadb": "0.5.1",
|
|
@@ -55,5 +58,5 @@
|
|
|
55
58
|
"url": "https://github.com/lambdadb/srcx/issues"
|
|
56
59
|
},
|
|
57
60
|
"license": "Apache-2.0",
|
|
58
|
-
"gitHead": "
|
|
61
|
+
"gitHead": "8c0d1656d4d66a6461a32e9072b2e5f7fda9cec1"
|
|
59
62
|
}
|
package/src/build.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type Preset = {
|
|
|
27
27
|
schemaVersion: 1;
|
|
28
28
|
chunker: typeof CHUNKER;
|
|
29
29
|
mode: "syntax" | "window";
|
|
30
|
+
/** Internal evaluation override; the CLI keeps its original enrichment. */
|
|
31
|
+
enrichment?: "path-only-v1";
|
|
30
32
|
maxFileBytes: number;
|
|
31
33
|
embedding: { model: string; dimensions: number } | null;
|
|
32
34
|
};
|
|
@@ -123,6 +125,7 @@ function exclude(path: string): string | undefined {
|
|
|
123
125
|
export async function materialize(args: {
|
|
124
126
|
identity: Identity;
|
|
125
127
|
ref: string;
|
|
128
|
+
resolvedCommit?: Awaited<ReturnType<typeof resolveCommit>>;
|
|
126
129
|
output: string;
|
|
127
130
|
previous?: Build;
|
|
128
131
|
preset?: Preset;
|
|
@@ -135,6 +138,8 @@ export async function materialize(args: {
|
|
|
135
138
|
invariant(
|
|
136
139
|
hash(preset.chunker) === hash(CHUNKER) &&
|
|
137
140
|
["syntax", "window"].includes(preset.mode) &&
|
|
141
|
+
(preset.enrichment === undefined ||
|
|
142
|
+
preset.enrichment === "path-only-v1") &&
|
|
138
143
|
preset.maxFileBytes === PRESET.maxFileBytes,
|
|
139
144
|
"Unsupported build preset.",
|
|
140
145
|
);
|
|
@@ -151,7 +156,8 @@ export async function materialize(args: {
|
|
|
151
156
|
args.embedder.dimensions === preset.embedding.dimensions),
|
|
152
157
|
"Embedding provider must match the pinned preset.",
|
|
153
158
|
);
|
|
154
|
-
const commit =
|
|
159
|
+
const commit =
|
|
160
|
+
args.resolvedCommit ?? (await resolveCommit(args.identity.path, args.ref));
|
|
155
161
|
const directory = resolve(args.output);
|
|
156
162
|
await mkdir(directory, { recursive: false, mode: 0o700 });
|
|
157
163
|
const output = await open(join(directory, "records.jsonl"), "wx", 0o600);
|
|
@@ -233,7 +239,12 @@ export async function materialize(args: {
|
|
|
233
239
|
item.reason = "lfs-pointer";
|
|
234
240
|
continue;
|
|
235
241
|
}
|
|
236
|
-
const parsed = await chunk(
|
|
242
|
+
const parsed = await chunk(
|
|
243
|
+
source,
|
|
244
|
+
e.path,
|
|
245
|
+
preset.mode,
|
|
246
|
+
preset.enrichment,
|
|
247
|
+
);
|
|
237
248
|
const contentHash = hash(data);
|
|
238
249
|
const fileId = `f-${hash([e.pathBase64, contentHash])}`;
|
|
239
250
|
const base = { schemaVersion: 1, configHash };
|
package/src/chunk.ts
CHANGED
|
@@ -75,7 +75,9 @@ export function detectLanguage(path: string): string {
|
|
|
75
75
|
)[ext ?? ""] ?? "text"
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
export type Enrichment = "path-scope-symbol-v1" | "path-only-v1";
|
|
79
|
+
function enrichment(path: string, u: Unit, policy: Enrichment): string {
|
|
80
|
+
if (policy === "path-only-v1") return path.slice(0, 240) + "\n";
|
|
79
81
|
return (
|
|
80
82
|
[path.slice(0, 240), u.scope?.slice(0, 160), u.symbol?.slice(0, 160)]
|
|
81
83
|
.filter(Boolean)
|
|
@@ -106,10 +108,11 @@ function splitUnit(
|
|
|
106
108
|
path: string,
|
|
107
109
|
u: Unit,
|
|
108
110
|
overlap: number,
|
|
111
|
+
policy: Enrichment,
|
|
109
112
|
): Unit[] {
|
|
110
113
|
const result: Unit[] = [];
|
|
111
114
|
let start = u.start;
|
|
112
|
-
const prefix = enrichment(path, u);
|
|
115
|
+
const prefix = enrichment(path, u, policy);
|
|
113
116
|
invariant(
|
|
114
117
|
tokens(prefix) < CHUNKER.maxTokens,
|
|
115
118
|
"Path/scope enrichment exceeds token limit.",
|
|
@@ -185,6 +188,7 @@ export async function chunk(
|
|
|
185
188
|
source: string,
|
|
186
189
|
path: string,
|
|
187
190
|
mode: "syntax" | "window" = "syntax",
|
|
191
|
+
policy: Enrichment = CHUNKER.enrichment,
|
|
188
192
|
): Promise<{ spans: Span[]; parseStatus: string; language: string }> {
|
|
189
193
|
const lang = detectLanguage(path);
|
|
190
194
|
if (!source.length)
|
|
@@ -224,7 +228,7 @@ export async function chunk(
|
|
|
224
228
|
if (
|
|
225
229
|
n.namedChildCount &&
|
|
226
230
|
(container ||
|
|
227
|
-
tokens(enrichment(path, u) + n.text) > CHUNKER.maxTokens)
|
|
231
|
+
tokens(enrichment(path, u, policy) + n.text) > CHUNKER.maxTokens)
|
|
228
232
|
) {
|
|
229
233
|
for (const child of n.namedChildren) if (child) visit(child);
|
|
230
234
|
} else if (n.endIndex > n.startIndex) units.push(u);
|
|
@@ -271,8 +275,9 @@ export async function chunk(
|
|
|
271
275
|
prev.kind !== "function" &&
|
|
272
276
|
u.kind === prev.kind &&
|
|
273
277
|
u.scope === prev.scope &&
|
|
274
|
-
tokens(
|
|
275
|
-
|
|
278
|
+
tokens(
|
|
279
|
+
enrichment(path, prev, policy) + source.slice(prev.start, u.end),
|
|
280
|
+
) <= CHUNKER.targetTokens
|
|
276
281
|
) {
|
|
277
282
|
prev.end = u.end;
|
|
278
283
|
delete prev.symbol;
|
|
@@ -280,7 +285,7 @@ export async function chunk(
|
|
|
280
285
|
} else merged.push({ ...u });
|
|
281
286
|
}
|
|
282
287
|
const bounded = merged.flatMap((u) =>
|
|
283
|
-
splitUnit(source, path, u, fallback ? CHUNKER.fallbackOverlap : 0),
|
|
288
|
+
splitUnit(source, path, u, fallback ? CHUNKER.fallbackOverlap : 0, policy),
|
|
284
289
|
);
|
|
285
290
|
const offsets = new Uint32Array(source.length + 1);
|
|
286
291
|
let byte = 0;
|
|
@@ -298,7 +303,7 @@ export async function chunk(
|
|
|
298
303
|
const startByte = offsets[u.start]!,
|
|
299
304
|
endByte = offsets[u.end]!;
|
|
300
305
|
const text = source.slice(u.start, u.end);
|
|
301
|
-
const searchText = enrichment(path, u) + text;
|
|
306
|
+
const searchText = enrichment(path, u, policy) + text;
|
|
302
307
|
const kind = text.trim() === "" ? "structural" : u.kind;
|
|
303
308
|
return {
|
|
304
309
|
startByte,
|
package/src/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { configure, loadSettings, stateRoot } from "./settings.js";
|
|
8
|
-
import { identity } from "./git.js";
|
|
8
|
+
import { identity, resolveCommit } from "./git.js";
|
|
9
9
|
import { loadBuild, materialize, validateBuild, type Build } from "./build.js";
|
|
10
10
|
import { invariant, optionalJson } from "./common.js";
|
|
11
11
|
import { LambdaRemote } from "./remote.js";
|
|
@@ -16,7 +16,13 @@ import {
|
|
|
16
16
|
register,
|
|
17
17
|
selectRepository,
|
|
18
18
|
} from "./repository.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
exclusive,
|
|
21
|
+
lastBuildPath,
|
|
22
|
+
publish,
|
|
23
|
+
published,
|
|
24
|
+
type Published,
|
|
25
|
+
} from "./publish.js";
|
|
20
26
|
import { resolveVersion, syncTags } from "./releases.js";
|
|
21
27
|
import { directHandle, loadHandle, readHandle, search } from "./search.js";
|
|
22
28
|
const cli = new Command()
|
|
@@ -147,14 +153,29 @@ cli
|
|
|
147
153
|
const buildLocal = async (
|
|
148
154
|
source: Awaited<ReturnType<typeof identity>>,
|
|
149
155
|
previous?: Build,
|
|
156
|
+
state?: string,
|
|
150
157
|
) => {
|
|
151
158
|
invariant(o.ref, "--ref is required to build a commit.");
|
|
159
|
+
const commit = await resolveCommit(source.path, o.ref);
|
|
160
|
+
if (state) {
|
|
161
|
+
const last = await optionalJson<{ artifact: string }>(
|
|
162
|
+
lastBuildPath(state, commit.branch),
|
|
163
|
+
);
|
|
164
|
+
if (last) {
|
|
165
|
+
try {
|
|
166
|
+
previous = await loadBuild(last.artifact);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
152
172
|
const parent = join(stateRoot(), "builds");
|
|
153
173
|
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
154
174
|
const path = o.output ? resolve(o.output) : join(parent, randomUUID());
|
|
155
175
|
return materialize({
|
|
156
176
|
identity: source,
|
|
157
177
|
ref: o.ref,
|
|
178
|
+
resolvedCommit: commit,
|
|
158
179
|
output: path,
|
|
159
180
|
previous,
|
|
160
181
|
});
|
|
@@ -212,20 +233,33 @@ cli
|
|
|
212
233
|
// Pending attempts reconcile against the journal's immutable remote
|
|
213
234
|
// baseline; they do not need the previous build's local files.
|
|
214
235
|
const pending = await optionalJson(join(state, "pending.json"));
|
|
236
|
+
const b = o.artifact
|
|
237
|
+
? await loadBuild(o.artifact)
|
|
238
|
+
: await buildLocal(
|
|
239
|
+
await attachment(settings, r),
|
|
240
|
+
undefined,
|
|
241
|
+
pending ? undefined : state,
|
|
242
|
+
);
|
|
215
243
|
let baseline: { version: Published; build: Build } | undefined;
|
|
216
|
-
|
|
244
|
+
// Tracked branches discover their own immutable baseline remotely.
|
|
245
|
+
// Manual SHA/tag imports retain the previous frozen workspace optimization.
|
|
246
|
+
if (!pending && !b.branchRef) {
|
|
217
247
|
const last = await optionalJson<{
|
|
218
248
|
artifact: string;
|
|
219
249
|
version: Published;
|
|
220
|
-
}>(
|
|
250
|
+
}>(lastBuildPath(state));
|
|
221
251
|
if (last) {
|
|
222
|
-
|
|
223
|
-
|
|
252
|
+
try {
|
|
253
|
+
baseline = {
|
|
254
|
+
version: last.version,
|
|
255
|
+
build: await loadBuild(last.artifact),
|
|
256
|
+
};
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
224
261
|
}
|
|
225
262
|
}
|
|
226
|
-
const b = o.artifact
|
|
227
|
-
? await loadBuild(o.artifact)
|
|
228
|
-
: await buildLocal(await attachment(settings, r), baseline?.build);
|
|
229
263
|
return publish({
|
|
230
264
|
store,
|
|
231
265
|
binding: r,
|
|
@@ -249,7 +283,7 @@ cli
|
|
|
249
283
|
cli
|
|
250
284
|
.command("resolve")
|
|
251
285
|
.requiredOption("--repo <name>")
|
|
252
|
-
.requiredOption("--ref <commit-or-release>")
|
|
286
|
+
.requiredOption("--ref <commit-branch-or-release>")
|
|
253
287
|
.action(async (o) => {
|
|
254
288
|
const { remote } = await connected();
|
|
255
289
|
const r = await selectRepository(remote, o.repo);
|
|
@@ -258,7 +292,7 @@ cli
|
|
|
258
292
|
cli
|
|
259
293
|
.command("search")
|
|
260
294
|
.requiredOption("--repo <name>")
|
|
261
|
-
.requiredOption("--version <commit-or-release>")
|
|
295
|
+
.requiredOption("--version <commit-branch-or-release>")
|
|
262
296
|
.requiredOption("--query <text>")
|
|
263
297
|
.option("--limit <count>", "Maximum hits", integer, 10)
|
|
264
298
|
.option("--path <path>", "Exact path filter")
|
|
@@ -279,7 +313,7 @@ cli
|
|
|
279
313
|
.command("read")
|
|
280
314
|
.option("--result <id>")
|
|
281
315
|
.option("--repo <name>")
|
|
282
|
-
.option("--version <commit-or-release>")
|
|
316
|
+
.option("--version <commit-branch-or-release>")
|
|
283
317
|
.option("--path <path>")
|
|
284
318
|
.option("--lines <start:end>")
|
|
285
319
|
.option("--context <lines>", "Expand an evidence span", integer, 0)
|
package/src/publish.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type Journal = {
|
|
|
36
36
|
attemptId: string;
|
|
37
37
|
writer: string;
|
|
38
38
|
source: string;
|
|
39
|
+
gitRef?: string;
|
|
40
|
+
reuseWriter?: boolean;
|
|
39
41
|
baseline?: Published;
|
|
40
42
|
phase: "branch" | "writing" | "waiting" | "publishing";
|
|
41
43
|
batches: {
|
|
@@ -45,6 +47,43 @@ export type Journal = {
|
|
|
45
47
|
}[];
|
|
46
48
|
candidate?: { name: string; snapshotId: string };
|
|
47
49
|
};
|
|
50
|
+
/** The applied snapshot may differ from the shared canonical commit Tag. */
|
|
51
|
+
export type TrackedBranch = Doc & {
|
|
52
|
+
gitRef: string;
|
|
53
|
+
writer: string;
|
|
54
|
+
applied?: Published;
|
|
55
|
+
publishedTagName?: string;
|
|
56
|
+
pending?: { attemptId: string; buildId: string; commitOid: string };
|
|
57
|
+
};
|
|
58
|
+
export function gitBranchName(ref: string): string {
|
|
59
|
+
invariant(
|
|
60
|
+
ref.startsWith("refs/heads/") && ref.length > 11,
|
|
61
|
+
"Expected a full Git branch ref.",
|
|
62
|
+
);
|
|
63
|
+
return `git-${hash(ref).slice(0, 40)}`;
|
|
64
|
+
}
|
|
65
|
+
export const gitBranchId = (ref: string): string => `branch-${hash(ref)}`;
|
|
66
|
+
export const lastBuildPath = (state: string, ref?: string): string =>
|
|
67
|
+
join(state, ref ? `last-build-${hash(ref)}.json` : "last-build.json");
|
|
68
|
+
export async function trackedBranch(
|
|
69
|
+
store: CollectionStore,
|
|
70
|
+
binding: Binding,
|
|
71
|
+
ref: string,
|
|
72
|
+
): Promise<TrackedBranch | undefined> {
|
|
73
|
+
const doc = await one(store, branch("main"), gitBranchId(ref), true);
|
|
74
|
+
if (!doc) return undefined;
|
|
75
|
+
invariant(
|
|
76
|
+
doc.kind === "manifest" &&
|
|
77
|
+
doc.role === "git-branch" &&
|
|
78
|
+
doc.gitRef === ref &&
|
|
79
|
+
doc.writer === gitBranchName(ref) &&
|
|
80
|
+
doc.repoId === binding.repoId &&
|
|
81
|
+
doc.indexId === binding.indexId &&
|
|
82
|
+
doc.configHash === binding.configHash,
|
|
83
|
+
"Tracked branch identity mismatch.",
|
|
84
|
+
);
|
|
85
|
+
return doc as TrackedBranch;
|
|
86
|
+
}
|
|
48
87
|
export function versionTag(oid: string): string {
|
|
49
88
|
return `ver-${hash(oid).slice(0, 40)}`;
|
|
50
89
|
}
|
|
@@ -255,7 +294,9 @@ export async function publish(args: {
|
|
|
255
294
|
invariant(
|
|
256
295
|
j.buildId === b.buildId &&
|
|
257
296
|
j.recordsHash === b.recordsHash &&
|
|
258
|
-
j.inventoryHash === b.inventoryHash
|
|
297
|
+
j.inventoryHash === b.inventoryHash &&
|
|
298
|
+
(j.gitRef === b.branchRef ||
|
|
299
|
+
(j.reuseWriter === undefined && j.writer.startsWith("work-"))),
|
|
259
300
|
"Another build is pending. Resume the artifact recorded in pending.json before importing a new commit.",
|
|
260
301
|
);
|
|
261
302
|
invariant(
|
|
@@ -266,6 +307,22 @@ export async function publish(args: {
|
|
|
266
307
|
const existing = (await published(store, binding)).find(
|
|
267
308
|
(v) => v.commitOid === b.commitOid,
|
|
268
309
|
);
|
|
310
|
+
const tracked = b.branchRef
|
|
311
|
+
? await trackedBranch(store, binding, b.branchRef)
|
|
312
|
+
: undefined;
|
|
313
|
+
if (tracked?.pending)
|
|
314
|
+
invariant(
|
|
315
|
+
j?.attemptId === tracked.pending.attemptId,
|
|
316
|
+
"Git branch has a pending import; resume with its original retry journal.",
|
|
317
|
+
);
|
|
318
|
+
if (j?.gitRef && j.phase !== "branch")
|
|
319
|
+
invariant(
|
|
320
|
+
tracked?.pending?.attemptId === j.attemptId ||
|
|
321
|
+
(j.phase === "publishing" &&
|
|
322
|
+
!tracked?.pending &&
|
|
323
|
+
tracked?.applied?.attemptId === j.attemptId),
|
|
324
|
+
"Tracked branch import ownership changed; refusing to replay this journal.",
|
|
325
|
+
);
|
|
269
326
|
if (existing) {
|
|
270
327
|
invariant(
|
|
271
328
|
await validateCandidate(
|
|
@@ -277,17 +334,79 @@ export async function publish(args: {
|
|
|
277
334
|
),
|
|
278
335
|
"Existing published version differs from this build.",
|
|
279
336
|
);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
337
|
+
// A shared commit Tag does not mean this Git branch has applied that commit.
|
|
338
|
+
if (!j && (!b.branchRef || tracked?.applied?.commitOid === b.commitOid)) {
|
|
339
|
+
if (tracked?.applied) {
|
|
340
|
+
const writer = (await store.branches()).find(
|
|
341
|
+
(x) => x.name === tracked.writer,
|
|
342
|
+
);
|
|
343
|
+
invariant(
|
|
344
|
+
writer?.snapshotId === tracked.applied.snapshotId,
|
|
345
|
+
"Writer baseline has changed; refusing to update tracked branch.",
|
|
346
|
+
);
|
|
347
|
+
invariant(
|
|
348
|
+
await validateCandidate(
|
|
349
|
+
store,
|
|
350
|
+
tracked.applied.tagName,
|
|
351
|
+
b,
|
|
352
|
+
binding,
|
|
353
|
+
tracked.applied.attemptId,
|
|
354
|
+
),
|
|
355
|
+
"Tracked branch baseline validation failed.",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
await atomic(lastBuildPath(state, b.branchRef), {
|
|
359
|
+
artifact: b.directory,
|
|
360
|
+
version: existing,
|
|
361
|
+
});
|
|
362
|
+
return existing;
|
|
363
|
+
}
|
|
364
|
+
// Legacy manual imports can finish bookkeeping after publication on retry.
|
|
365
|
+
if (j && !j.gitRef) {
|
|
366
|
+
await atomic(lastBuildPath(state), {
|
|
367
|
+
artifact: b.directory,
|
|
368
|
+
version: existing,
|
|
369
|
+
});
|
|
370
|
+
await rm(journalPath);
|
|
371
|
+
return existing;
|
|
372
|
+
}
|
|
286
373
|
}
|
|
287
374
|
if (!j) {
|
|
288
375
|
let source = "checkpoint-empty";
|
|
289
376
|
let baseline: Published | undefined;
|
|
290
|
-
if (
|
|
377
|
+
if (b.branchRef) {
|
|
378
|
+
const writer = (await store.branches()).find(
|
|
379
|
+
(x) => x.name === gitBranchName(b.branchRef!),
|
|
380
|
+
);
|
|
381
|
+
if (tracked?.applied) {
|
|
382
|
+
baseline = tracked.applied;
|
|
383
|
+
invariant(
|
|
384
|
+
writer?.snapshotId === baseline.snapshotId,
|
|
385
|
+
"Writer baseline has changed; refusing to update tracked branch.",
|
|
386
|
+
);
|
|
387
|
+
const pin = (await store.tags()).find(
|
|
388
|
+
(t) => t.name === baseline!.tagName,
|
|
389
|
+
);
|
|
390
|
+
const root = await one(store, tag(baseline.tagName), "__manifest__");
|
|
391
|
+
invariant(
|
|
392
|
+
pin?.snapshotId === baseline.snapshotId &&
|
|
393
|
+
root?.repoId === binding.repoId &&
|
|
394
|
+
root.indexId === binding.indexId &&
|
|
395
|
+
root.configHash === binding.configHash &&
|
|
396
|
+
root.buildId === baseline.buildId &&
|
|
397
|
+
root.attemptId === baseline.attemptId &&
|
|
398
|
+
root.recordsHash === baseline.recordsHash &&
|
|
399
|
+
root.inventoryHash === baseline.inventoryHash &&
|
|
400
|
+
root.commitOid === baseline.commitOid,
|
|
401
|
+
"Tracked branch baseline Tag is missing or changed.",
|
|
402
|
+
);
|
|
403
|
+
} else {
|
|
404
|
+
invariant(
|
|
405
|
+
!writer && !tracked,
|
|
406
|
+
"Unowned or unfinished tracked branch; retain its retry journal.",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
} else if (args.baseline?.version.writer.startsWith("work-")) {
|
|
291
410
|
const prev = args.baseline;
|
|
292
411
|
await validateBuild(prev.build);
|
|
293
412
|
invariant(
|
|
@@ -322,7 +441,9 @@ export async function publish(args: {
|
|
|
322
441
|
inventoryHash: b.inventoryHash,
|
|
323
442
|
artifact: b.directory,
|
|
324
443
|
attemptId,
|
|
325
|
-
writer: `work-${attemptId}`,
|
|
444
|
+
writer: b.branchRef ? gitBranchName(b.branchRef) : `work-${attemptId}`,
|
|
445
|
+
gitRef: b.branchRef,
|
|
446
|
+
reuseWriter: !!tracked?.applied,
|
|
326
447
|
source,
|
|
327
448
|
baseline,
|
|
328
449
|
phase: "branch",
|
|
@@ -332,15 +453,48 @@ export async function publish(args: {
|
|
|
332
453
|
}
|
|
333
454
|
const save = () => atomic(journalPath, j);
|
|
334
455
|
if (j.phase === "branch") {
|
|
456
|
+
if (j.gitRef) {
|
|
457
|
+
// Record intent before any corpus mutation, including before branch creation.
|
|
458
|
+
// A fresh local state must not treat a lagging, partially written head as clean.
|
|
459
|
+
const current = await trackedBranch(store, binding, j.gitRef);
|
|
460
|
+
invariant(
|
|
461
|
+
(!current?.pending || current.pending.attemptId === j.attemptId) &&
|
|
462
|
+
current?.applied?.snapshotId === j.baseline?.snapshotId,
|
|
463
|
+
"Git branch ownership or baseline changed before import.",
|
|
464
|
+
);
|
|
465
|
+
await store.upsert("main", [
|
|
466
|
+
{
|
|
467
|
+
...current,
|
|
468
|
+
id: gitBranchId(j.gitRef),
|
|
469
|
+
kind: "manifest",
|
|
470
|
+
role: "git-branch",
|
|
471
|
+
schemaVersion: 1,
|
|
472
|
+
repoId: binding.repoId,
|
|
473
|
+
indexId: binding.indexId,
|
|
474
|
+
configHash: binding.configHash,
|
|
475
|
+
gitRef: j.gitRef,
|
|
476
|
+
writer: j.writer,
|
|
477
|
+
pending: {
|
|
478
|
+
attemptId: j.attemptId,
|
|
479
|
+
buildId: j.buildId,
|
|
480
|
+
commitOid: b.commitOid,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
]);
|
|
484
|
+
}
|
|
335
485
|
const branches = await store.branches();
|
|
336
486
|
const found = branches.find((x) => x.name === j!.writer);
|
|
337
487
|
if (found)
|
|
338
488
|
invariant(
|
|
339
|
-
found.parent === j.source &&
|
|
489
|
+
(j.reuseWriter || found.parent === j.source) &&
|
|
340
490
|
found.snapshotId === (j.baseline?.snapshotId ?? null),
|
|
341
491
|
"Partially created writer has an unexpected baseline.",
|
|
342
492
|
);
|
|
343
493
|
else {
|
|
494
|
+
invariant(
|
|
495
|
+
!j.reuseWriter,
|
|
496
|
+
"Tracked writer is missing; refusing to recreate a nonempty baseline.",
|
|
497
|
+
);
|
|
344
498
|
const source = branches.find((x) => x.name === j!.source);
|
|
345
499
|
invariant(
|
|
346
500
|
source &&
|
|
@@ -454,10 +608,12 @@ export async function publish(args: {
|
|
|
454
608
|
const found = (await store.tags()).find((t) => t.name === name);
|
|
455
609
|
const final = found ?? (await store.tag(name, tag(j.candidate.name)));
|
|
456
610
|
invariant(
|
|
457
|
-
|
|
611
|
+
existing
|
|
612
|
+
? final.snapshotId === existing.snapshotId
|
|
613
|
+
: final.snapshotId === j.candidate.snapshotId,
|
|
458
614
|
"Published Tag name already points to a different snapshot.",
|
|
459
615
|
);
|
|
460
|
-
const result: Published = {
|
|
616
|
+
const result: Published = existing ?? {
|
|
461
617
|
id: versionId(b.commitOid),
|
|
462
618
|
kind: "manifest",
|
|
463
619
|
role: "published",
|
|
@@ -477,8 +633,42 @@ export async function publish(args: {
|
|
|
477
633
|
counts: b.counts,
|
|
478
634
|
validation: { inventory: true, content: true, query: true },
|
|
479
635
|
};
|
|
480
|
-
await store.upsert("main", [result]);
|
|
481
|
-
|
|
636
|
+
if (!existing) await store.upsert("main", [result]);
|
|
637
|
+
if (j.gitRef) {
|
|
638
|
+
const current = await trackedBranch(store, binding, j.gitRef);
|
|
639
|
+
invariant(
|
|
640
|
+
current?.pending?.attemptId === j.attemptId ||
|
|
641
|
+
(!current?.pending && current?.applied?.attemptId === j.attemptId),
|
|
642
|
+
"Tracked branch publication ownership changed.",
|
|
643
|
+
);
|
|
644
|
+
// Pin the exact writer baseline even when another branch published this commit first.
|
|
645
|
+
const applied: Published = {
|
|
646
|
+
...result,
|
|
647
|
+
writer: j.writer,
|
|
648
|
+
snapshotId: j.candidate.snapshotId,
|
|
649
|
+
tagName:
|
|
650
|
+
final.snapshotId === j.candidate.snapshotId
|
|
651
|
+
? final.name
|
|
652
|
+
: j.candidate.name,
|
|
653
|
+
attemptId: j.attemptId,
|
|
654
|
+
};
|
|
655
|
+
await store.upsert("main", [
|
|
656
|
+
{
|
|
657
|
+
id: gitBranchId(j.gitRef),
|
|
658
|
+
kind: "manifest",
|
|
659
|
+
role: "git-branch",
|
|
660
|
+
schemaVersion: 1,
|
|
661
|
+
repoId: binding.repoId,
|
|
662
|
+
indexId: binding.indexId,
|
|
663
|
+
configHash: binding.configHash,
|
|
664
|
+
gitRef: j.gitRef,
|
|
665
|
+
writer: j.writer,
|
|
666
|
+
applied,
|
|
667
|
+
publishedTagName: result.tagName,
|
|
668
|
+
},
|
|
669
|
+
]);
|
|
670
|
+
}
|
|
671
|
+
await atomic(lastBuildPath(state, j.gitRef), {
|
|
482
672
|
artifact: b.directory,
|
|
483
673
|
version: result,
|
|
484
674
|
});
|
package/src/releases.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { hash, invariant, type Doc } from "./common.js";
|
|
2
2
|
import { gitTags } from "./git.js";
|
|
3
3
|
import { one, branch, type CollectionStore } from "./remote.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
published,
|
|
6
|
+
trackedBranch,
|
|
7
|
+
type Binding,
|
|
8
|
+
type Published,
|
|
9
|
+
} from "./publish.js";
|
|
5
10
|
export const releaseAlias = (ref: string) => `rel-${hash(ref).slice(0, 40)}`;
|
|
6
11
|
export const releaseId = (ref: string) => `ref-${hash(ref)}`;
|
|
7
12
|
/** Only observed local tags. Absence is not deletion authority; this command never prunes. */
|
|
@@ -58,11 +63,39 @@ export async function resolveVersion(
|
|
|
58
63
|
binding: Binding,
|
|
59
64
|
selector: string,
|
|
60
65
|
): Promise<Published> {
|
|
61
|
-
const versions = await published(store, binding);
|
|
62
66
|
const ref = selector.startsWith("refs/tags/")
|
|
63
67
|
? selector
|
|
64
68
|
: `refs/tags/${selector}`;
|
|
65
|
-
const control =
|
|
69
|
+
const control = selector.startsWith("refs/heads/")
|
|
70
|
+
? undefined
|
|
71
|
+
: await one(store, branch("main"), releaseId(ref), true);
|
|
72
|
+
const headRef = selector.startsWith("refs/heads/")
|
|
73
|
+
? selector
|
|
74
|
+
: `refs/heads/${selector}`;
|
|
75
|
+
const head =
|
|
76
|
+
!selector.startsWith("refs/") || selector.startsWith("refs/heads/")
|
|
77
|
+
? await trackedBranch(store, binding, headRef)
|
|
78
|
+
: undefined;
|
|
79
|
+
invariant(
|
|
80
|
+
!(head && control),
|
|
81
|
+
"Ambiguous branch/tag name: use refs/heads/... or refs/tags/...",
|
|
82
|
+
);
|
|
83
|
+
// Pin moving ref control before listing versions. Otherwise publication can
|
|
84
|
+
// advance the ref to a version absent from the earlier list.
|
|
85
|
+
const versions = await published(store, binding);
|
|
86
|
+
if (head) {
|
|
87
|
+
invariant(
|
|
88
|
+
head.applied,
|
|
89
|
+
"Git branch has no published version yet; finish its import.",
|
|
90
|
+
);
|
|
91
|
+
const result = versions.find(
|
|
92
|
+
(v) =>
|
|
93
|
+
v.commitOid === head.applied!.commitOid &&
|
|
94
|
+
v.tagName === head.publishedTagName,
|
|
95
|
+
);
|
|
96
|
+
invariant(result, "Git branch target is not a published version.");
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
66
99
|
// Like Git import, a known tag name takes precedence over an OID spelling.
|
|
67
100
|
// Its pending or invalid state must not fall back to an unrelated commit.
|
|
68
101
|
if (!control && !selector.startsWith("refs/")) {
|