@functional-systems/srcx 0.1.0-dev.1 → 0.1.0-dev.10
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 +56 -1
- package/DESIGN.md +102 -34
- package/README.md +163 -12
- package/RELEASING.md +17 -8
- package/VALIDATION.md +325 -19
- package/dist/build.d.ts +73 -1
- package/dist/build.js +122 -21
- 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 +55 -18
- package/dist/cli.js.map +1 -1
- package/dist/publish.d.ts +18 -0
- package/dist/publish.js +154 -19
- package/dist/publish.js.map +1 -1
- package/dist/releases.js +21 -3
- package/dist/releases.js.map +1 -1
- package/dist/remote.js +38 -9
- package/dist/remote.js.map +1 -1
- package/dist/repository.d.ts +4 -1
- package/dist/repository.js +15 -11
- package/dist/repository.js.map +1 -1
- package/dist/search.d.ts +7 -2
- package/dist/search.js +24 -4
- package/dist/search.js.map +1 -1
- package/package.json +13 -5
- package/src/build.ts +166 -24
- package/src/chunk.ts +12 -7
- package/src/cli.ts +105 -22
- package/src/publish.ts +222 -20
- package/src/releases.ts +36 -3
- package/src/remote.ts +59 -13
- package/src/repository.ts +20 -11
- package/src/search.ts +37 -8
package/src/cli.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { Command, InvalidArgumentError } from "commander";
|
|
3
|
+
import { Command, InvalidArgumentError, Option } from "commander";
|
|
4
4
|
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";
|
|
9
|
-
import {
|
|
8
|
+
import { identity, resolveCommit } from "./git.js";
|
|
9
|
+
import {
|
|
10
|
+
loadBuild,
|
|
11
|
+
materialize,
|
|
12
|
+
validateBuild,
|
|
13
|
+
type Build,
|
|
14
|
+
type Preset,
|
|
15
|
+
PRESET,
|
|
16
|
+
presetFor,
|
|
17
|
+
} from "./build.js";
|
|
10
18
|
import { invariant, optionalJson } from "./common.js";
|
|
11
19
|
import { LambdaRemote } from "./remote.js";
|
|
12
20
|
import {
|
|
@@ -16,7 +24,13 @@ import {
|
|
|
16
24
|
register,
|
|
17
25
|
selectRepository,
|
|
18
26
|
} from "./repository.js";
|
|
19
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
exclusive,
|
|
29
|
+
lastBuildPath,
|
|
30
|
+
publish,
|
|
31
|
+
published,
|
|
32
|
+
type Published,
|
|
33
|
+
} from "./publish.js";
|
|
20
34
|
import { resolveVersion, syncTags } from "./releases.js";
|
|
21
35
|
import { directHandle, loadHandle, readHandle, search } from "./search.js";
|
|
22
36
|
const cli = new Command()
|
|
@@ -81,6 +95,14 @@ repo
|
|
|
81
95
|
.option("--remote <name>")
|
|
82
96
|
.option("--description <text>")
|
|
83
97
|
.option("--tag <key=value>", "One optional Collection metadata tag")
|
|
98
|
+
.addOption(
|
|
99
|
+
new Option(
|
|
100
|
+
"--embedding <model>",
|
|
101
|
+
"Managed model (source text is sent to the provider on import)",
|
|
102
|
+
)
|
|
103
|
+
.choices(["none", "text-embedding-3-small", "text-embedding-3-large"])
|
|
104
|
+
.default("none"),
|
|
105
|
+
)
|
|
84
106
|
.action(async (o) => {
|
|
85
107
|
const { remote } = await connected();
|
|
86
108
|
let labels: Record<string, string> | undefined;
|
|
@@ -95,6 +117,7 @@ repo
|
|
|
95
117
|
remote: o.remote,
|
|
96
118
|
description: o.description,
|
|
97
119
|
labels,
|
|
120
|
+
preset: presetFor(o.embedding),
|
|
98
121
|
}),
|
|
99
122
|
);
|
|
100
123
|
});
|
|
@@ -122,6 +145,12 @@ cli
|
|
|
122
145
|
.option("--repo <name>", "Registered remote repository")
|
|
123
146
|
.option("--ref <ref>", "Local branch, Git tag, or commit OID")
|
|
124
147
|
.option("--dry-run", "Only materialize and validate a local build")
|
|
148
|
+
.addOption(
|
|
149
|
+
new Option(
|
|
150
|
+
"--embedding <model>",
|
|
151
|
+
"Preset for --dry-run --path; connected imports use the repository preset",
|
|
152
|
+
).choices(["none", "text-embedding-3-small", "text-embedding-3-large"]),
|
|
153
|
+
)
|
|
125
154
|
.option("--output <directory>", "New artifact directory")
|
|
126
155
|
.option(
|
|
127
156
|
"--previous <directory>",
|
|
@@ -134,6 +163,10 @@ cli
|
|
|
134
163
|
)
|
|
135
164
|
.option("--timeout <seconds>", "Validation wait deadline", integer, 120)
|
|
136
165
|
.action(async (o) => {
|
|
166
|
+
invariant(
|
|
167
|
+
!o.embedding || (o.dryRun && o.path),
|
|
168
|
+
"--embedding is only accepted with --dry-run --path; connected imports use the repository preset.",
|
|
169
|
+
);
|
|
137
170
|
invariant(!(o.path && o.repo), "Choose --path or --repo.");
|
|
138
171
|
invariant(
|
|
139
172
|
!o.previous || o.dryRun,
|
|
@@ -147,16 +180,33 @@ cli
|
|
|
147
180
|
const buildLocal = async (
|
|
148
181
|
source: Awaited<ReturnType<typeof identity>>,
|
|
149
182
|
previous?: Build,
|
|
183
|
+
state?: string,
|
|
184
|
+
preset: Preset = PRESET,
|
|
150
185
|
) => {
|
|
151
186
|
invariant(o.ref, "--ref is required to build a commit.");
|
|
187
|
+
const commit = await resolveCommit(source.path, o.ref);
|
|
188
|
+
if (state) {
|
|
189
|
+
const last = await optionalJson<{ artifact: string }>(
|
|
190
|
+
lastBuildPath(state, commit.branch),
|
|
191
|
+
);
|
|
192
|
+
if (last) {
|
|
193
|
+
try {
|
|
194
|
+
previous = await loadBuild(last.artifact);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
152
200
|
const parent = join(stateRoot(), "builds");
|
|
153
201
|
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
154
202
|
const path = o.output ? resolve(o.output) : join(parent, randomUUID());
|
|
155
203
|
return materialize({
|
|
156
204
|
identity: source,
|
|
157
205
|
ref: o.ref,
|
|
206
|
+
resolvedCommit: commit,
|
|
158
207
|
output: path,
|
|
159
208
|
previous,
|
|
209
|
+
preset,
|
|
160
210
|
});
|
|
161
211
|
};
|
|
162
212
|
if (o.dryRun) {
|
|
@@ -166,18 +216,20 @@ cli
|
|
|
166
216
|
await validateBuild(b);
|
|
167
217
|
} else {
|
|
168
218
|
let source;
|
|
219
|
+
let preset = presetFor(o.embedding);
|
|
169
220
|
if (o.path) source = await identity(o.path, o.remote);
|
|
170
221
|
else {
|
|
171
222
|
invariant(o.repo, "Use --path for a credential-free dry run.");
|
|
172
223
|
const { remote, settings } = await connected();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
);
|
|
224
|
+
const selected = await selectRepository(remote, o.repo);
|
|
225
|
+
preset = selected.preset ?? PRESET;
|
|
226
|
+
source = await attachment(settings, selected);
|
|
177
227
|
}
|
|
178
228
|
b = await buildLocal(
|
|
179
229
|
source,
|
|
180
230
|
o.previous ? await loadBuild(o.previous) : undefined,
|
|
231
|
+
undefined,
|
|
232
|
+
preset,
|
|
181
233
|
);
|
|
182
234
|
}
|
|
183
235
|
output({
|
|
@@ -212,20 +264,34 @@ cli
|
|
|
212
264
|
// Pending attempts reconcile against the journal's immutable remote
|
|
213
265
|
// baseline; they do not need the previous build's local files.
|
|
214
266
|
const pending = await optionalJson(join(state, "pending.json"));
|
|
267
|
+
const b = o.artifact
|
|
268
|
+
? await loadBuild(o.artifact)
|
|
269
|
+
: await buildLocal(
|
|
270
|
+
await attachment(settings, r),
|
|
271
|
+
undefined,
|
|
272
|
+
pending ? undefined : state,
|
|
273
|
+
r.preset ?? PRESET,
|
|
274
|
+
);
|
|
215
275
|
let baseline: { version: Published; build: Build } | undefined;
|
|
216
|
-
|
|
276
|
+
// Tracked branches discover their own immutable baseline remotely.
|
|
277
|
+
// Manual SHA/tag imports retain the previous frozen workspace optimization.
|
|
278
|
+
if (!pending && !b.branchRef) {
|
|
217
279
|
const last = await optionalJson<{
|
|
218
280
|
artifact: string;
|
|
219
281
|
version: Published;
|
|
220
|
-
}>(
|
|
282
|
+
}>(lastBuildPath(state));
|
|
221
283
|
if (last) {
|
|
222
|
-
|
|
223
|
-
|
|
284
|
+
try {
|
|
285
|
+
baseline = {
|
|
286
|
+
version: last.version,
|
|
287
|
+
build: await loadBuild(last.artifact),
|
|
288
|
+
};
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
224
293
|
}
|
|
225
294
|
}
|
|
226
|
-
const b = o.artifact
|
|
227
|
-
? await loadBuild(o.artifact)
|
|
228
|
-
: await buildLocal(await attachment(settings, r), baseline?.build);
|
|
229
295
|
return publish({
|
|
230
296
|
store,
|
|
231
297
|
binding: r,
|
|
@@ -249,7 +315,7 @@ cli
|
|
|
249
315
|
cli
|
|
250
316
|
.command("resolve")
|
|
251
317
|
.requiredOption("--repo <name>")
|
|
252
|
-
.requiredOption("--ref <commit-or-release>")
|
|
318
|
+
.requiredOption("--ref <commit-branch-or-release>")
|
|
253
319
|
.action(async (o) => {
|
|
254
320
|
const { remote } = await connected();
|
|
255
321
|
const r = await selectRepository(remote, o.repo);
|
|
@@ -258,9 +324,17 @@ cli
|
|
|
258
324
|
cli
|
|
259
325
|
.command("search")
|
|
260
326
|
.requiredOption("--repo <name>")
|
|
261
|
-
.requiredOption("--version <commit-or-release>")
|
|
327
|
+
.requiredOption("--version <commit-branch-or-release>")
|
|
262
328
|
.requiredOption("--query <text>")
|
|
263
329
|
.option("--limit <count>", "Maximum hits", integer, 10)
|
|
330
|
+
.addOption(
|
|
331
|
+
new Option(
|
|
332
|
+
"--mode <mode>",
|
|
333
|
+
"Retrieval method; semantic/hybrid incur managed query embedding usage",
|
|
334
|
+
)
|
|
335
|
+
.choices(["lexical", "semantic", "hybrid"])
|
|
336
|
+
.default("lexical"),
|
|
337
|
+
)
|
|
264
338
|
.option("--path <path>", "Exact path filter")
|
|
265
339
|
.option("--language <name>")
|
|
266
340
|
.action(async (o) => {
|
|
@@ -269,17 +343,26 @@ cli
|
|
|
269
343
|
store = remote.store(r.collection),
|
|
270
344
|
v = await resolveVersion(store, r, o.version);
|
|
271
345
|
output(
|
|
272
|
-
await search(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
346
|
+
await search(
|
|
347
|
+
store,
|
|
348
|
+
settings,
|
|
349
|
+
r,
|
|
350
|
+
v,
|
|
351
|
+
o.query,
|
|
352
|
+
o.limit,
|
|
353
|
+
{
|
|
354
|
+
path: o.path,
|
|
355
|
+
language: o.language,
|
|
356
|
+
},
|
|
357
|
+
o.mode,
|
|
358
|
+
),
|
|
276
359
|
);
|
|
277
360
|
});
|
|
278
361
|
cli
|
|
279
362
|
.command("read")
|
|
280
363
|
.option("--result <id>")
|
|
281
364
|
.option("--repo <name>")
|
|
282
|
-
.option("--version <commit-or-release>")
|
|
365
|
+
.option("--version <commit-branch-or-release>")
|
|
283
366
|
.option("--path <path>")
|
|
284
367
|
.option("--lines <start:end>")
|
|
285
368
|
.option("--context <lines>", "Expand an evidence span", integer, 0)
|
package/src/publish.ts
CHANGED
|
@@ -10,7 +10,14 @@ import {
|
|
|
10
10
|
sleep,
|
|
11
11
|
type Doc,
|
|
12
12
|
} from "./common.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
corpusMetadata,
|
|
15
|
+
records,
|
|
16
|
+
recordHash,
|
|
17
|
+
supportedPreset,
|
|
18
|
+
validateBuild,
|
|
19
|
+
type Build,
|
|
20
|
+
} from "./build.js";
|
|
14
21
|
import { branch, tag, one, type CollectionStore } from "./remote.js";
|
|
15
22
|
export type Binding = {
|
|
16
23
|
repoId: string;
|
|
@@ -36,6 +43,8 @@ export type Journal = {
|
|
|
36
43
|
attemptId: string;
|
|
37
44
|
writer: string;
|
|
38
45
|
source: string;
|
|
46
|
+
gitRef?: string;
|
|
47
|
+
reuseWriter?: boolean;
|
|
39
48
|
baseline?: Published;
|
|
40
49
|
phase: "branch" | "writing" | "waiting" | "publishing";
|
|
41
50
|
batches: {
|
|
@@ -45,6 +54,43 @@ export type Journal = {
|
|
|
45
54
|
}[];
|
|
46
55
|
candidate?: { name: string; snapshotId: string };
|
|
47
56
|
};
|
|
57
|
+
/** The applied snapshot may differ from the shared canonical commit Tag. */
|
|
58
|
+
export type TrackedBranch = Doc & {
|
|
59
|
+
gitRef: string;
|
|
60
|
+
writer: string;
|
|
61
|
+
applied?: Published;
|
|
62
|
+
publishedTagName?: string;
|
|
63
|
+
pending?: { attemptId: string; buildId: string; commitOid: string };
|
|
64
|
+
};
|
|
65
|
+
export function gitBranchName(ref: string): string {
|
|
66
|
+
invariant(
|
|
67
|
+
ref.startsWith("refs/heads/") && ref.length > 11,
|
|
68
|
+
"Expected a full Git branch ref.",
|
|
69
|
+
);
|
|
70
|
+
return `git-${hash(ref).slice(0, 40)}`;
|
|
71
|
+
}
|
|
72
|
+
export const gitBranchId = (ref: string): string => `branch-${hash(ref)}`;
|
|
73
|
+
export const lastBuildPath = (state: string, ref?: string): string =>
|
|
74
|
+
join(state, ref ? `last-build-${hash(ref)}.json` : "last-build.json");
|
|
75
|
+
export async function trackedBranch(
|
|
76
|
+
store: CollectionStore,
|
|
77
|
+
binding: Binding,
|
|
78
|
+
ref: string,
|
|
79
|
+
): Promise<TrackedBranch | undefined> {
|
|
80
|
+
const doc = await one(store, branch("main"), gitBranchId(ref), true);
|
|
81
|
+
if (!doc) return undefined;
|
|
82
|
+
invariant(
|
|
83
|
+
doc.kind === "manifest" &&
|
|
84
|
+
doc.role === "git-branch" &&
|
|
85
|
+
doc.gitRef === ref &&
|
|
86
|
+
doc.writer === gitBranchName(ref) &&
|
|
87
|
+
doc.repoId === binding.repoId &&
|
|
88
|
+
doc.indexId === binding.indexId &&
|
|
89
|
+
doc.configHash === binding.configHash,
|
|
90
|
+
"Tracked branch identity mismatch.",
|
|
91
|
+
);
|
|
92
|
+
return doc as TrackedBranch;
|
|
93
|
+
}
|
|
48
94
|
export function versionTag(oid: string): string {
|
|
49
95
|
return `ver-${hash(oid).slice(0, 40)}`;
|
|
50
96
|
}
|
|
@@ -115,7 +161,11 @@ export async function validateCandidate(
|
|
|
115
161
|
for (const d of corpusMetadata(b, binding.repoId, binding.indexId, attemptId))
|
|
116
162
|
expected.set(d.id, hash(d));
|
|
117
163
|
for await (const d of store.list(tag(name))) {
|
|
118
|
-
|
|
164
|
+
try {
|
|
165
|
+
if (expected.get(d.id) !== recordHash(d, b.preset)) return false;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
119
169
|
expected.delete(d.id);
|
|
120
170
|
}
|
|
121
171
|
if (expected.size) return false;
|
|
@@ -164,7 +214,8 @@ export async function validateCandidate(
|
|
|
164
214
|
!found.length ||
|
|
165
215
|
found.some(
|
|
166
216
|
(h) =>
|
|
167
|
-
h.doc.kind !== "chunk" ||
|
|
217
|
+
h.doc.kind !== "chunk" ||
|
|
218
|
+
b.recordHashes[h.doc.id] !== recordHash(h.doc, b.preset),
|
|
168
219
|
)
|
|
169
220
|
)
|
|
170
221
|
return false;
|
|
@@ -245,8 +296,8 @@ export async function publish(args: {
|
|
|
245
296
|
"Build does not belong to this index.",
|
|
246
297
|
);
|
|
247
298
|
invariant(
|
|
248
|
-
!b.preset.embedding,
|
|
249
|
-
"
|
|
299
|
+
!b.preset.embedding || supportedPreset(b.preset),
|
|
300
|
+
"Publication requires a supported lexical or managed embedding preset.",
|
|
250
301
|
);
|
|
251
302
|
await validateBuild(b);
|
|
252
303
|
const journalPath = join(state, "pending.json");
|
|
@@ -255,7 +306,9 @@ export async function publish(args: {
|
|
|
255
306
|
invariant(
|
|
256
307
|
j.buildId === b.buildId &&
|
|
257
308
|
j.recordsHash === b.recordsHash &&
|
|
258
|
-
j.inventoryHash === b.inventoryHash
|
|
309
|
+
j.inventoryHash === b.inventoryHash &&
|
|
310
|
+
(j.gitRef === b.branchRef ||
|
|
311
|
+
(j.reuseWriter === undefined && j.writer.startsWith("work-"))),
|
|
259
312
|
"Another build is pending. Resume the artifact recorded in pending.json before importing a new commit.",
|
|
260
313
|
);
|
|
261
314
|
invariant(
|
|
@@ -266,6 +319,22 @@ export async function publish(args: {
|
|
|
266
319
|
const existing = (await published(store, binding)).find(
|
|
267
320
|
(v) => v.commitOid === b.commitOid,
|
|
268
321
|
);
|
|
322
|
+
const tracked = b.branchRef
|
|
323
|
+
? await trackedBranch(store, binding, b.branchRef)
|
|
324
|
+
: undefined;
|
|
325
|
+
if (tracked?.pending)
|
|
326
|
+
invariant(
|
|
327
|
+
j?.attemptId === tracked.pending.attemptId,
|
|
328
|
+
"Git branch has a pending import; resume with its original retry journal.",
|
|
329
|
+
);
|
|
330
|
+
if (j?.gitRef && j.phase !== "branch")
|
|
331
|
+
invariant(
|
|
332
|
+
tracked?.pending?.attemptId === j.attemptId ||
|
|
333
|
+
(j.phase === "publishing" &&
|
|
334
|
+
!tracked?.pending &&
|
|
335
|
+
tracked?.applied?.attemptId === j.attemptId),
|
|
336
|
+
"Tracked branch import ownership changed; refusing to replay this journal.",
|
|
337
|
+
);
|
|
269
338
|
if (existing) {
|
|
270
339
|
invariant(
|
|
271
340
|
await validateCandidate(
|
|
@@ -277,17 +346,79 @@ export async function publish(args: {
|
|
|
277
346
|
),
|
|
278
347
|
"Existing published version differs from this build.",
|
|
279
348
|
);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
349
|
+
// A shared commit Tag does not mean this Git branch has applied that commit.
|
|
350
|
+
if (!j && (!b.branchRef || tracked?.applied?.commitOid === b.commitOid)) {
|
|
351
|
+
if (tracked?.applied) {
|
|
352
|
+
const writer = (await store.branches()).find(
|
|
353
|
+
(x) => x.name === tracked.writer,
|
|
354
|
+
);
|
|
355
|
+
invariant(
|
|
356
|
+
writer?.snapshotId === tracked.applied.snapshotId,
|
|
357
|
+
"Writer baseline has changed; refusing to update tracked branch.",
|
|
358
|
+
);
|
|
359
|
+
invariant(
|
|
360
|
+
await validateCandidate(
|
|
361
|
+
store,
|
|
362
|
+
tracked.applied.tagName,
|
|
363
|
+
b,
|
|
364
|
+
binding,
|
|
365
|
+
tracked.applied.attemptId,
|
|
366
|
+
),
|
|
367
|
+
"Tracked branch baseline validation failed.",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
await atomic(lastBuildPath(state, b.branchRef), {
|
|
371
|
+
artifact: b.directory,
|
|
372
|
+
version: existing,
|
|
373
|
+
});
|
|
374
|
+
return existing;
|
|
375
|
+
}
|
|
376
|
+
// Legacy manual imports can finish bookkeeping after publication on retry.
|
|
377
|
+
if (j && !j.gitRef) {
|
|
378
|
+
await atomic(lastBuildPath(state), {
|
|
379
|
+
artifact: b.directory,
|
|
380
|
+
version: existing,
|
|
381
|
+
});
|
|
382
|
+
await rm(journalPath);
|
|
383
|
+
return existing;
|
|
384
|
+
}
|
|
286
385
|
}
|
|
287
386
|
if (!j) {
|
|
288
387
|
let source = "checkpoint-empty";
|
|
289
388
|
let baseline: Published | undefined;
|
|
290
|
-
if (
|
|
389
|
+
if (b.branchRef) {
|
|
390
|
+
const writer = (await store.branches()).find(
|
|
391
|
+
(x) => x.name === gitBranchName(b.branchRef!),
|
|
392
|
+
);
|
|
393
|
+
if (tracked?.applied) {
|
|
394
|
+
baseline = tracked.applied;
|
|
395
|
+
invariant(
|
|
396
|
+
writer?.snapshotId === baseline.snapshotId,
|
|
397
|
+
"Writer baseline has changed; refusing to update tracked branch.",
|
|
398
|
+
);
|
|
399
|
+
const pin = (await store.tags()).find(
|
|
400
|
+
(t) => t.name === baseline!.tagName,
|
|
401
|
+
);
|
|
402
|
+
const root = await one(store, tag(baseline.tagName), "__manifest__");
|
|
403
|
+
invariant(
|
|
404
|
+
pin?.snapshotId === baseline.snapshotId &&
|
|
405
|
+
root?.repoId === binding.repoId &&
|
|
406
|
+
root.indexId === binding.indexId &&
|
|
407
|
+
root.configHash === binding.configHash &&
|
|
408
|
+
root.buildId === baseline.buildId &&
|
|
409
|
+
root.attemptId === baseline.attemptId &&
|
|
410
|
+
root.recordsHash === baseline.recordsHash &&
|
|
411
|
+
root.inventoryHash === baseline.inventoryHash &&
|
|
412
|
+
root.commitOid === baseline.commitOid,
|
|
413
|
+
"Tracked branch baseline Tag is missing or changed.",
|
|
414
|
+
);
|
|
415
|
+
} else {
|
|
416
|
+
invariant(
|
|
417
|
+
!writer && !tracked,
|
|
418
|
+
"Unowned or unfinished tracked branch; retain its retry journal.",
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
} else if (args.baseline?.version.writer.startsWith("work-")) {
|
|
291
422
|
const prev = args.baseline;
|
|
292
423
|
await validateBuild(prev.build);
|
|
293
424
|
invariant(
|
|
@@ -322,7 +453,9 @@ export async function publish(args: {
|
|
|
322
453
|
inventoryHash: b.inventoryHash,
|
|
323
454
|
artifact: b.directory,
|
|
324
455
|
attemptId,
|
|
325
|
-
writer: `work-${attemptId}`,
|
|
456
|
+
writer: b.branchRef ? gitBranchName(b.branchRef) : `work-${attemptId}`,
|
|
457
|
+
gitRef: b.branchRef,
|
|
458
|
+
reuseWriter: !!tracked?.applied,
|
|
326
459
|
source,
|
|
327
460
|
baseline,
|
|
328
461
|
phase: "branch",
|
|
@@ -332,15 +465,48 @@ export async function publish(args: {
|
|
|
332
465
|
}
|
|
333
466
|
const save = () => atomic(journalPath, j);
|
|
334
467
|
if (j.phase === "branch") {
|
|
468
|
+
if (j.gitRef) {
|
|
469
|
+
// Record intent before any corpus mutation, including before branch creation.
|
|
470
|
+
// A fresh local state must not treat a lagging, partially written head as clean.
|
|
471
|
+
const current = await trackedBranch(store, binding, j.gitRef);
|
|
472
|
+
invariant(
|
|
473
|
+
(!current?.pending || current.pending.attemptId === j.attemptId) &&
|
|
474
|
+
current?.applied?.snapshotId === j.baseline?.snapshotId,
|
|
475
|
+
"Git branch ownership or baseline changed before import.",
|
|
476
|
+
);
|
|
477
|
+
await store.upsert("main", [
|
|
478
|
+
{
|
|
479
|
+
...current,
|
|
480
|
+
id: gitBranchId(j.gitRef),
|
|
481
|
+
kind: "manifest",
|
|
482
|
+
role: "git-branch",
|
|
483
|
+
schemaVersion: 1,
|
|
484
|
+
repoId: binding.repoId,
|
|
485
|
+
indexId: binding.indexId,
|
|
486
|
+
configHash: binding.configHash,
|
|
487
|
+
gitRef: j.gitRef,
|
|
488
|
+
writer: j.writer,
|
|
489
|
+
pending: {
|
|
490
|
+
attemptId: j.attemptId,
|
|
491
|
+
buildId: j.buildId,
|
|
492
|
+
commitOid: b.commitOid,
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
]);
|
|
496
|
+
}
|
|
335
497
|
const branches = await store.branches();
|
|
336
498
|
const found = branches.find((x) => x.name === j!.writer);
|
|
337
499
|
if (found)
|
|
338
500
|
invariant(
|
|
339
|
-
found.parent === j.source &&
|
|
501
|
+
(j.reuseWriter || found.parent === j.source) &&
|
|
340
502
|
found.snapshotId === (j.baseline?.snapshotId ?? null),
|
|
341
503
|
"Partially created writer has an unexpected baseline.",
|
|
342
504
|
);
|
|
343
505
|
else {
|
|
506
|
+
invariant(
|
|
507
|
+
!j.reuseWriter,
|
|
508
|
+
"Tracked writer is missing; refusing to recreate a nonempty baseline.",
|
|
509
|
+
);
|
|
344
510
|
const source = branches.find((x) => x.name === j!.source);
|
|
345
511
|
invariant(
|
|
346
512
|
source &&
|
|
@@ -368,7 +534,7 @@ export async function publish(args: {
|
|
|
368
534
|
if (j.baseline)
|
|
369
535
|
for await (const d of store.list(tag(j.baseline.tagName))) {
|
|
370
536
|
oldIds.add(d.id);
|
|
371
|
-
oldHashes.set(d.id,
|
|
537
|
+
oldHashes.set(d.id, recordHash(d, b.preset));
|
|
372
538
|
}
|
|
373
539
|
const newIds = new Set([
|
|
374
540
|
...Object.keys(b.recordHashes),
|
|
@@ -454,10 +620,12 @@ export async function publish(args: {
|
|
|
454
620
|
const found = (await store.tags()).find((t) => t.name === name);
|
|
455
621
|
const final = found ?? (await store.tag(name, tag(j.candidate.name)));
|
|
456
622
|
invariant(
|
|
457
|
-
|
|
623
|
+
existing
|
|
624
|
+
? final.snapshotId === existing.snapshotId
|
|
625
|
+
: final.snapshotId === j.candidate.snapshotId,
|
|
458
626
|
"Published Tag name already points to a different snapshot.",
|
|
459
627
|
);
|
|
460
|
-
const result: Published = {
|
|
628
|
+
const result: Published = existing ?? {
|
|
461
629
|
id: versionId(b.commitOid),
|
|
462
630
|
kind: "manifest",
|
|
463
631
|
role: "published",
|
|
@@ -477,8 +645,42 @@ export async function publish(args: {
|
|
|
477
645
|
counts: b.counts,
|
|
478
646
|
validation: { inventory: true, content: true, query: true },
|
|
479
647
|
};
|
|
480
|
-
await store.upsert("main", [result]);
|
|
481
|
-
|
|
648
|
+
if (!existing) await store.upsert("main", [result]);
|
|
649
|
+
if (j.gitRef) {
|
|
650
|
+
const current = await trackedBranch(store, binding, j.gitRef);
|
|
651
|
+
invariant(
|
|
652
|
+
current?.pending?.attemptId === j.attemptId ||
|
|
653
|
+
(!current?.pending && current?.applied?.attemptId === j.attemptId),
|
|
654
|
+
"Tracked branch publication ownership changed.",
|
|
655
|
+
);
|
|
656
|
+
// Pin the exact writer baseline even when another branch published this commit first.
|
|
657
|
+
const applied: Published = {
|
|
658
|
+
...result,
|
|
659
|
+
writer: j.writer,
|
|
660
|
+
snapshotId: j.candidate.snapshotId,
|
|
661
|
+
tagName:
|
|
662
|
+
final.snapshotId === j.candidate.snapshotId
|
|
663
|
+
? final.name
|
|
664
|
+
: j.candidate.name,
|
|
665
|
+
attemptId: j.attemptId,
|
|
666
|
+
};
|
|
667
|
+
await store.upsert("main", [
|
|
668
|
+
{
|
|
669
|
+
id: gitBranchId(j.gitRef),
|
|
670
|
+
kind: "manifest",
|
|
671
|
+
role: "git-branch",
|
|
672
|
+
schemaVersion: 1,
|
|
673
|
+
repoId: binding.repoId,
|
|
674
|
+
indexId: binding.indexId,
|
|
675
|
+
configHash: binding.configHash,
|
|
676
|
+
gitRef: j.gitRef,
|
|
677
|
+
writer: j.writer,
|
|
678
|
+
applied,
|
|
679
|
+
publishedTagName: result.tagName,
|
|
680
|
+
},
|
|
681
|
+
]);
|
|
682
|
+
}
|
|
683
|
+
await atomic(lastBuildPath(state, j.gitRef), {
|
|
482
684
|
artifact: b.directory,
|
|
483
685
|
version: result,
|
|
484
686
|
});
|
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/")) {
|