@functional-systems/srcx 0.1.0-dev.3 → 0.1.0-dev.5

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/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 { exclusive, publish, published, type Published } from "./publish.js";
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
- if (!pending) {
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
- }>(join(state, "last-build.json"));
250
+ }>(lastBuildPath(state));
221
251
  if (last) {
222
- const b = await loadBuild(last.artifact);
223
- baseline = { version: last.version, build: b };
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
- await atomic(join(state, "last-build.json"), {
281
- artifact: b.directory,
282
- version: existing,
283
- });
284
- if (j) await rm(journalPath);
285
- return existing;
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 (args.baseline) {
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
- final.snapshotId === j.candidate.snapshotId,
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
- await atomic(join(state, "last-build.json"), {
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 { published, type Binding, type Published } from "./publish.js";
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 = await one(store, branch("main"), releaseId(ref), true);
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/")) {