@camstack/addon-training 0.1.6 → 0.1.7

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.
Files changed (43) hide show
  1. package/dist/MaskShapeCanvas-Kfg9dl6B.mjs +686 -0
  2. package/dist/{MotionZonesSettings-DPtcv80q.mjs → MotionZonesSettings-DKtYdHUO.mjs} +101 -119
  3. package/dist/{PrivacyMaskSettings-Wsn5QL0_.mjs → PrivacyMaskSettings-BvLUVz86.mjs} +45 -70
  4. package/dist/ReactKonva-DGuYUN0L.mjs +5416 -0
  5. package/dist/{SceneMonitorEditor-BlMwyiH8.mjs → SceneMonitorEditor-rFYGkGHV.mjs} +82 -82
  6. package/dist/_stub.js +28146 -12781
  7. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_training_page-Cbrdn9ib.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_training_page-B3xXUcaU.mjs} +3 -3
  8. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C7dks3FG.mjs +26 -0
  9. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-DpjirGmB.mjs +89 -0
  10. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-CFCVuRAs.mjs +58 -0
  11. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.js-BkvDudqY.mjs +62 -0
  12. package/dist/{_virtual_mf___mfe_internal__addon_training_page__loadShare__react__loadShare__.js-BT_wL8s3.mjs → _virtual_mf___mfe_internal__addon_training_page__loadShare__react__loadShare__.js-DeDbF7NI.mjs} +1 -1
  13. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-DnrbBvrw.mjs +34 -0
  14. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare__react_mf_2_dom__loadShare__.js-DAZkHxxU.mjs +45 -0
  15. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-CHqN9NLt.mjs +34 -0
  16. package/dist/addon-training.css +1 -1
  17. package/dist/{dist-BhCRZEE4.js → dist-DVDBSrjw.js} +270 -146
  18. package/dist/{dist-Cqa2fHNk.mjs → dist-Phot2UwD.mjs} +271 -141
  19. package/dist/grid-2x2-Bl3xw1w7.mjs +21 -0
  20. package/dist/{hostInit-BI98RYIm.mjs → hostInit-BAGO4XYA.mjs} +2 -2
  21. package/dist/index.js +100 -39
  22. package/dist/index.mjs +99 -38
  23. package/dist/lib-CVGFTwe0.mjs +7770 -0
  24. package/dist/orchestrator/training.addon.js +376 -222
  25. package/dist/orchestrator/training.addon.mjs +376 -222
  26. package/dist/{player-overlays-BX3obOMu.mjs → player-overlays-BT-6fK9N.mjs} +26 -7
  27. package/dist/providers/modal/training-modal.addon.js +361 -155
  28. package/dist/providers/modal/training-modal.addon.mjs +364 -158
  29. package/dist/remoteEntry.js +1 -1
  30. package/dist/{responsive-CdDIJCpI.mjs → responsive-Dy5RAD1s.mjs} +42 -38
  31. package/dist/{scene-monitor-copy-5u5w3K-Y.mjs → scene-monitor-copy-7HImRbA5.mjs} +4 -4
  32. package/dist/{square-C-P1-89Q.mjs → square-DoPBhbZN.mjs} +29 -4
  33. package/dist/{trash-2-BikGYiWf.mjs → trash-2-BwunNgLf.mjs} +1 -1
  34. package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_training_page__remoteEntry_js-DGLUs0o6.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_training_page__remoteEntry_js-gU49KvnZ.mjs} +399 -390
  35. package/package.json +1 -1
  36. package/dist/MaskShapeCanvas-BokARvOj.mjs +0 -13806
  37. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BkKuzhOJ.mjs +0 -26
  38. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-CaB1jnkt.mjs +0 -25
  39. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-B3rryujf.mjs +0 -25
  40. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.js-nL4MGFRP.mjs +0 -26
  41. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-DDBEvLdo.mjs +0 -26
  42. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare__react_mf_2_dom__loadShare__.js-i_pnGDxd.mjs +0 -25
  43. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-gTIQrhKs.mjs +0 -25
@@ -1,4 +1,4 @@
1
- import { a as TrainingResultSchema, c as trainingCapability, i as TrainingJobSpecSchema, n as TrainingDatasetVersionRowSchema, o as addonPagesSourceCapability, r as TrainingJobSchema, s as parseTrainingEventLine, t as TRAINING_JOB_SCHEMA, u as BaseAddon } from "../dist-Cqa2fHNk.mjs";
1
+ import { a as addonPagesSourceCapability, i as TrainingResultSchema, l as BaseAddon, n as TrainingJobSchema, o as parseTrainingEventLine, r as TrainingJobSpecSchema, s as trainingCapability, t as TRAINING_JOB_SCHEMA } from "../dist-Phot2UwD.mjs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
@@ -6,22 +6,123 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  //#region src/orchestrator/api-ports.ts
7
7
  var DETECTOR_STEP = "object-detection";
8
8
  var REGISTRY_CAP = "custom-model-registry";
9
- /** The provider cap, routed to ONE provider by the `addonId` every call carries. */
9
+ /** The provider cap, with entryId → owning addon resolution for collection pins. */
10
10
  function providerPort(api) {
11
11
  const p = api.trainingProvider;
12
+ const addonForEntry = async (entryId) => {
13
+ const entry = (await p.listEntries.query({})).find((e) => e.entryId === entryId);
14
+ if (entry === void 0) throw new Error(`unknown training provider entry "${entryId}"`);
15
+ return entry.addonId;
16
+ };
17
+ const addonForType = async (typeId) => {
18
+ const type = (await p.describeTypes.query({})).find((t) => t.typeId === typeId);
19
+ if (type === void 0) throw new Error(`unknown training provider type "${typeId}"`);
20
+ return type.addonId;
21
+ };
12
22
  return {
13
23
  describe: () => p.describe.query({}),
14
- validateCredentials: (i) => p.validateCredentials.mutate(i),
15
- estimateJob: (i) => p.estimateJob.query(i),
16
- uploadDataset: (i) => p.uploadDataset.mutate(i),
17
- submitJob: (i) => p.submitJob.mutate(i),
18
- getJob: (i) => p.getJob.query(i),
19
- listJobEvents: (i) => p.listJobEvents.query(i),
20
- cancelJob: (i) => p.cancelJob.mutate(i),
21
- readResult: (i) => p.readResult.mutate(i),
22
- offerArtifact: (i) => p.offerArtifact.mutate(i),
23
- cleanupJob: (i) => p.cleanupJob.mutate(i),
24
- listJobs: (i) => p.listJobs.query(i)
24
+ describeTypes: () => p.describeTypes.query({}),
25
+ listEntries: () => p.listEntries.query({}),
26
+ createEntry: async (i) => {
27
+ const addonId = await addonForType(i.typeId);
28
+ return p.createEntry.mutate({
29
+ ...i,
30
+ addonId
31
+ });
32
+ },
33
+ deleteEntry: async (i) => {
34
+ const addonId = await addonForEntry(i.entryId);
35
+ return p.deleteEntry.mutate({
36
+ ...i,
37
+ addonId
38
+ });
39
+ },
40
+ validateCredentials: async (i) => {
41
+ const addonId = await addonForEntry(i.entryId);
42
+ return p.validateCredentials.mutate({
43
+ ...i,
44
+ addonId
45
+ });
46
+ },
47
+ estimateJob: async (i) => {
48
+ const addonId = await addonForEntry(i.entryId);
49
+ return p.estimateJob.query({
50
+ ...i,
51
+ addonId
52
+ });
53
+ },
54
+ uploadDataset: async (i) => {
55
+ const addonId = await addonForEntry(i.entryId);
56
+ return p.uploadDataset.mutate({
57
+ ...i,
58
+ addonId
59
+ });
60
+ },
61
+ submitJob: async (i) => {
62
+ const addonId = await addonForEntry(i.entryId);
63
+ return p.submitJob.mutate({
64
+ ...i,
65
+ addonId
66
+ });
67
+ },
68
+ getJob: async (i) => {
69
+ const addonId = await addonForEntry(i.entryId);
70
+ return p.getJob.query({
71
+ ...i,
72
+ addonId
73
+ });
74
+ },
75
+ listJobEvents: async (i) => {
76
+ const addonId = await addonForEntry(i.entryId);
77
+ return p.listJobEvents.query({
78
+ ...i,
79
+ addonId
80
+ });
81
+ },
82
+ cancelJob: async (i) => {
83
+ const addonId = await addonForEntry(i.entryId);
84
+ return p.cancelJob.mutate({
85
+ ...i,
86
+ addonId
87
+ });
88
+ },
89
+ readResult: async (i) => {
90
+ const addonId = await addonForEntry(i.entryId);
91
+ return p.readResult.mutate({
92
+ ...i,
93
+ addonId
94
+ });
95
+ },
96
+ offerArtifact: async (i) => {
97
+ const addonId = await addonForEntry(i.entryId);
98
+ return p.offerArtifact.mutate({
99
+ ...i,
100
+ addonId
101
+ });
102
+ },
103
+ cleanupJob: async (i) => {
104
+ const addonId = await addonForEntry(i.entryId);
105
+ return p.cleanupJob.mutate({
106
+ ...i,
107
+ addonId
108
+ });
109
+ },
110
+ listJobs: async (i) => {
111
+ if (i.entryId !== void 0) {
112
+ const addonId = await addonForEntry(i.entryId);
113
+ return p.listJobs.query({
114
+ ...i,
115
+ addonId
116
+ });
117
+ }
118
+ const types = await p.describeTypes.query({});
119
+ const seen = /* @__PURE__ */ new Set();
120
+ return (await Promise.all(types.map(async (t) => {
121
+ if (seen.has(t.addonId)) return [];
122
+ seen.add(t.addonId);
123
+ return p.listJobs.query({ addonId: t.addonId });
124
+ }))).flat();
125
+ }
25
126
  };
26
127
  }
27
128
  function datasetPort(api) {
@@ -36,10 +137,13 @@ function datasetPort(api) {
36
137
  };
37
138
  } };
38
139
  }
140
+ function corpusPort(api) {
141
+ return { resolve: (selection) => api.pipelineAnalytics.resolveRetrainTrainingCorpus.query(selection) };
142
+ }
39
143
  /** Model Studio's registry: the hub's provider of `custom-model-registry`. */
40
144
  function registryPort(api) {
41
145
  return { importBundle: async (input) => {
42
- const hub = (await api.addons.listCapabilityProviders.query({ capName: REGISTRY_CAP })).find((p) => !p.addonId.includes("@"));
146
+ const hub = (await api.addons.listCapabilityProviders.query({ capName: REGISTRY_CAP })).find((prov) => !prov.addonId.includes("@"));
43
147
  if (hub === void 0) throw new Error("no custom-model registry on the hub — is Model Studio installed?");
44
148
  return api.customModelRegistry.importModelBundle.mutate({
45
149
  ...input,
@@ -538,27 +642,36 @@ function dataNoticeFor(provider, region, frames) {
538
642
  return `${String(frames)} frames and their boxes are uploaded to YOUR ${provider.label} account, ${where}. ${provider.dataResidencyNote} They are deleted there as soon as the result is fetched (${String(grace)} h after a failure, so it can be inspected), and they are never put in a log, an environment variable or the job file. The result stays on this hub for ${String(14)} days; nothing is registered until you click Register.`;
539
643
  }
540
644
  async function planJob(request, deps) {
541
- const version = await deps.versions.get(request.datasetVersionId);
542
- if (version === null) return refused("unknown-dataset", `dataset version "${request.datasetVersionId}" does not exist`);
543
- const provider = (await deps.provider.describe({})).find((p) => p.addonId === request.addonId);
544
- if (provider === void 0) return refused("unknown-provider", `no training provider "${request.addonId}"`);
545
- const notice = dataNoticeFor(provider, request.region, version.frameIds.length);
546
- if (!provider.configured) return refused("provider-not-configured", `${provider.label} has no credentials yet — set them in its settings`, notice);
645
+ const resolved = await deps.corpus.resolve(request.selection);
646
+ if (resolved.kind === "refused") {
647
+ if (resolved.reason === "unknown-collection") return refused("unknown-dataset", resolved.detail);
648
+ return refused("no-training-subjects", resolved.detail);
649
+ }
650
+ const corpus = {
651
+ frameIds: resolved.frameIds,
652
+ stats: resolved.stats,
653
+ annotationHash: resolved.annotationHash,
654
+ layout: resolved.layout
655
+ };
656
+ const provider = (await deps.provider.describe({})).find((p) => p.entryId === request.entryId);
657
+ if (provider === void 0) return refused("unknown-provider", `no training provider entry "${request.entryId}"`);
658
+ const notice = dataNoticeFor(provider, request.region, corpus.frameIds.length);
659
+ if (!provider.configured) return refused("provider-not-configured", `${provider.name} (${provider.label}) has no credentials yet — set them on the entry`, notice);
547
660
  const base = findBaseModel(request.baseModelId);
548
661
  if (base === null) return refused("unknown-base-model", `"${request.baseModelId}" is not a trainable base`, notice);
549
662
  if (!knownGpu(provider.gpuTypes, request.gpuType)) return refused("unknown-gpu", `${provider.label} offers no "${request.gpuType}"`, notice);
550
- const datasetBytes = datasetBytesEstimate(version.frameIds.length);
663
+ const datasetBytes = datasetBytesEstimate(corpus.frameIds.length);
551
664
  if (datasetBytes > provider.maxDatasetBytes) return refused("dataset-too-large", `about ${String(Math.round(datasetBytes / 1e6))} MB is over ${provider.label}'s limit`, notice);
552
- const { split, warnings } = planSplit(version.stats, request.holdoutCameras);
665
+ const { split, warnings } = planSplit(corpus.stats, request.holdoutCameras);
553
666
  if (split.trainSubjects <= 0) return refused("no-training-subjects", "no boxed subject is left to train on once the holdout is set aside", notice);
554
667
  const gpu = estimateGpuSeconds({
555
- frames: version.frameIds.length,
668
+ frames: corpus.frameIds.length,
556
669
  base,
557
670
  preset: request.preset,
558
671
  gpuType: request.gpuType
559
672
  });
560
673
  const usd = await deps.provider.estimateJob({
561
- addonId: request.addonId,
674
+ entryId: request.entryId,
562
675
  gpuType: request.gpuType,
563
676
  region: request.region,
564
677
  datasetBytes,
@@ -568,7 +681,7 @@ async function planJob(request, deps) {
568
681
  const maxCostUsd = defaultCostCap(usd.usdHigh, provider.defaults.costCapUsd);
569
682
  const allWarnings = [...warnings];
570
683
  if (usd.usdHigh > maxCostUsd) allWarnings.push(`The high estimate ($${usd.usdHigh.toFixed(2)}) is above the cost cap ($${maxCostUsd.toFixed(2)}): the job may be stopped before it finishes.`);
571
- if (version.stats.completeness < .5) allWarnings.push("Fewer than half of the frames are marked complete: evaluation uses complete frames only, so the comparison rests on few frames.");
684
+ if (corpus.stats.completeness < .5) allWarnings.push("Fewer than half of the frames are marked complete: evaluation uses complete frames only, so the comparison rests on few frames.");
572
685
  return {
573
686
  plan: {
574
687
  refusal: null,
@@ -590,7 +703,7 @@ async function planJob(request, deps) {
590
703
  dataNotice: notice
591
704
  },
592
705
  accepted: {
593
- version,
706
+ corpus,
594
707
  provider,
595
708
  base,
596
709
  split
@@ -668,14 +781,14 @@ var TrainingEngine = class {
668
781
  kind: "started",
669
782
  job: existing
670
783
  };
671
- const active = (await this.ports.jobs.list()).find((j) => ACTIVE.has(j.state));
672
- if (active !== void 0) return {
784
+ const busy = this.busyJob(await this.ports.jobs.list());
785
+ if (busy !== void 0) return {
673
786
  kind: "refused",
674
787
  reason: "job-already-running",
675
- detail: `"${active.name}" is still ${active.state}`
788
+ detail: `"${busy.name}" is still ${busy.state}`
676
789
  };
677
790
  const planned = await planJob(input, {
678
- versions: this.ports.versions,
791
+ corpus: this.ports.corpus,
679
792
  provider: this.ports.provider
680
793
  });
681
794
  if (planned.plan.refusal !== null || planned.accepted === null) {
@@ -689,55 +802,24 @@ var TrainingEngine = class {
689
802
  detail: refusal.detail
690
803
  };
691
804
  }
692
- const { version, split } = planned.accepted;
693
- const now = this.ports.now();
694
805
  const { name, maxCostUsd, maxRuntimeSec, idempotencyKey, ...request } = input;
695
- const job = {
696
- id: this.ports.newId(),
697
- idempotencyKey,
698
- name,
699
- createdAt: now,
700
- updatedAt: now,
701
- state: "uploading",
702
- stage: null,
703
- failure: null,
806
+ const job = this.newJobRow({
704
807
  request,
808
+ name,
705
809
  maxCostUsd,
706
810
  maxRuntimeSec,
707
- trainer: this.trainer,
708
- datasetLayout: version.layout,
709
- dataset: null,
710
- split,
711
- provider: {
712
- state: null,
713
- providerJobId: null,
714
- startedAt: null,
715
- endedAt: null,
716
- billedSeconds: 0,
717
- costUsd: 0,
718
- costIsEstimate: true,
719
- lastPolledAt: null
720
- },
721
- progress: {
722
- epoch: null,
723
- epochs: null,
724
- etaSec: null,
725
- series: []
726
- },
727
- logTail: [],
728
- logCursor: null,
729
- artifacts: null,
730
- cloudDatasetDeletedAt: null,
731
- cloudCleanupDueAt: null,
732
- registeredModelIds: [],
733
- canary: null
734
- };
811
+ idempotencyKey,
812
+ batchId: null,
813
+ state: "uploading",
814
+ accepted: planned.accepted
815
+ });
735
816
  await this.ports.jobs.put(job);
736
817
  this.ports.logger.info("training job started", { meta: {
737
818
  jobId: job.id,
738
- provider: request.addonId,
819
+ provider: request.entryId,
739
820
  gpu: request.gpuType,
740
- frames: version.frameIds.length
821
+ frames: job.frameCount,
822
+ collections: request.selection.collectionIds.length
741
823
  } });
742
824
  this.runTask(job.id, () => this.uploadAndSubmit(job.id));
743
825
  return {
@@ -745,9 +827,109 @@ var TrainingEngine = class {
745
827
  job
746
828
  };
747
829
  }
830
+ /**
831
+ * One job per base model, same live selection snapshot. The first uploads;
832
+ * siblings stay `queued` until the prior job in the batch settles.
833
+ */
834
+ async startJobBatch(input) {
835
+ const existing = await this.ports.jobs.findByIdempotencyKey(input.idempotencyKey);
836
+ if (existing !== null) return {
837
+ kind: "started",
838
+ batchId: existing.batchId ?? existing.id,
839
+ jobs: existing.batchId === null ? [existing] : (await this.ports.jobs.list()).filter((j) => j.batchId === existing.batchId).toSorted((a, b) => a.createdAt - b.createdAt)
840
+ };
841
+ const busy = this.busyJob(await this.ports.jobs.list());
842
+ if (busy !== void 0) return {
843
+ kind: "refused",
844
+ reason: "job-already-running",
845
+ detail: `"${busy.name}" is still ${busy.state}`
846
+ };
847
+ const models = uniqueBaseModelIds(input.baseModelIds);
848
+ for (const modelId of models) if (findBaseModel(modelId) === null) return {
849
+ kind: "refused",
850
+ reason: "unknown-base-model",
851
+ detail: `"${modelId}" is not a trainable base`
852
+ };
853
+ const headModel = models[0];
854
+ if (headModel === void 0) return {
855
+ kind: "refused",
856
+ reason: "unknown-base-model",
857
+ detail: "no base models given"
858
+ };
859
+ const shared = {
860
+ selection: input.selection,
861
+ entryId: input.entryId,
862
+ gpuType: input.gpuType,
863
+ region: input.region,
864
+ preset: input.preset,
865
+ freeze: input.freeze,
866
+ holdoutCameras: input.holdoutCameras
867
+ };
868
+ const planned = await planJob({
869
+ ...shared,
870
+ baseModelId: headModel
871
+ }, {
872
+ corpus: this.ports.corpus,
873
+ provider: this.ports.provider
874
+ });
875
+ if (planned.plan.refusal !== null || planned.accepted === null) {
876
+ const refusal = planned.plan.refusal ?? {
877
+ reason: "unknown-dataset",
878
+ detail: "no plan"
879
+ };
880
+ return {
881
+ kind: "refused",
882
+ reason: refusal.reason,
883
+ detail: refusal.detail
884
+ };
885
+ }
886
+ const batchId = this.ports.newId();
887
+ const jobs = [];
888
+ for (let i = 0; i < models.length; i += 1) {
889
+ const modelId = models[i];
890
+ if (modelId === void 0) continue;
891
+ const job = this.newJobRow({
892
+ request: {
893
+ ...shared,
894
+ baseModelId: modelId
895
+ },
896
+ name: input.name,
897
+ maxCostUsd: input.maxCostUsd,
898
+ maxRuntimeSec: input.maxRuntimeSec,
899
+ idempotencyKey: i === 0 ? input.idempotencyKey : `${input.idempotencyKey}:${modelId}`,
900
+ batchId,
901
+ state: i === 0 ? "uploading" : "queued",
902
+ accepted: planned.accepted
903
+ });
904
+ await this.ports.jobs.put(job);
905
+ jobs.push(job);
906
+ }
907
+ const head = jobs[0];
908
+ if (head === void 0) return {
909
+ kind: "refused",
910
+ reason: "unknown-base-model",
911
+ detail: "no base models given"
912
+ };
913
+ this.ports.logger.info("training job batch started", { meta: {
914
+ batchId,
915
+ jobIds: jobs.map((j) => j.id),
916
+ models: [...models],
917
+ frames: head.frameCount
918
+ } });
919
+ this.runTask(head.id, () => this.uploadAndSubmit(head.id));
920
+ return {
921
+ kind: "started",
922
+ batchId,
923
+ jobs
924
+ };
925
+ }
748
926
  async cancelJob(id) {
749
927
  const job = await this.ports.jobs.get(id);
750
928
  if (job === null) throw new Error(`training job "${id}" does not exist`);
929
+ if (job.state === "queued") return await this.mutator.update(id, (j) => ({
930
+ ...j,
931
+ state: "cancelled"
932
+ })) ?? job;
751
933
  if (!ACTIVE.has(job.state) || job.state === "cancelling") return job;
752
934
  const moved = await this.mutator.update(id, (j) => ({
753
935
  ...j,
@@ -755,7 +937,7 @@ var TrainingEngine = class {
755
937
  }));
756
938
  if (job.state !== "uploading") try {
757
939
  await this.ports.provider.cancelJob({
758
- addonId: job.request.addonId,
940
+ entryId: job.request.entryId,
759
941
  jobKey: id
760
942
  });
761
943
  } catch (err) {
@@ -786,7 +968,7 @@ var TrainingEngine = class {
786
968
  const swept = [];
787
969
  for (const provider of await this.ports.provider.describe({})) {
788
970
  if (!provider.configured) continue;
789
- for (const status of await this.ports.provider.listJobs({ addonId: provider.addonId })) {
971
+ for (const status of await this.ports.provider.listJobs({ entryId: provider.entryId })) {
790
972
  const record = known.get(status.jobKey);
791
973
  const live = record !== void 0 && !SETTLED.has(record.state);
792
974
  const stillRunning = status.state === "pending" || status.state === "starting" || status.state === "running";
@@ -797,11 +979,11 @@ var TrainingEngine = class {
797
979
  state: status.state
798
980
  } });
799
981
  await this.ports.provider.cancelJob({
800
- addonId: provider.addonId,
982
+ entryId: provider.entryId,
801
983
  jobKey: status.jobKey
802
984
  });
803
985
  await this.ports.provider.cleanupJob({
804
- addonId: provider.addonId,
986
+ entryId: provider.entryId,
805
987
  jobKey: status.jobKey
806
988
  });
807
989
  swept.push(status.jobKey);
@@ -822,23 +1004,19 @@ var TrainingEngine = class {
822
1004
  async uploadAndSubmit(id) {
823
1005
  const job = await this.ports.jobs.get(id);
824
1006
  if (job === null) return;
825
- const version = await this.ports.versions.get(job.request.datasetVersionId);
826
1007
  const base = findBaseModel(job.request.baseModelId);
827
- if (version === null || base === null) {
828
- await this.fail(id, "upload-failed", null, "the dataset version or base model disappeared");
1008
+ if (base === null || job.frameIds.length === 0) {
1009
+ await this.fail(id, "upload-failed", null, "the corpus snapshot or base model disappeared");
829
1010
  return;
830
1011
  }
831
1012
  let uploaded;
832
1013
  try {
833
1014
  const offer = await this.ports.datasets.offerArchive({
834
- filter: {
835
- frameIds: [...version.frameIds],
836
- ...version.completeOnly ? { completeOnly: true } : {}
837
- },
838
- layout: version.layout
1015
+ filter: { frameIds: [...job.frameIds] },
1016
+ layout: job.datasetLayout
839
1017
  });
840
1018
  uploaded = await this.ports.provider.uploadDataset({
841
- addonId: job.request.addonId,
1019
+ entryId: job.request.entryId,
842
1020
  jobKey: id,
843
1021
  ticket: offer.ticket
844
1022
  });
@@ -863,12 +1041,12 @@ var TrainingEngine = class {
863
1041
  const spec = buildJobSpec({
864
1042
  job: afterUpload ?? job,
865
1043
  base,
866
- versionId: version.id,
867
- annotationHash: version.annotationHash,
1044
+ versionId: `live-${job.annotationHash.slice(0, 12)}`,
1045
+ annotationHash: job.annotationHash,
868
1046
  datasetSha256: uploaded.sha256
869
1047
  });
870
1048
  const status = await this.ports.provider.submitJob({
871
- addonId: job.request.addonId,
1049
+ entryId: job.request.entryId,
872
1050
  jobKey: id,
873
1051
  trainer: job.trainer,
874
1052
  jobSpecJson: JSON.stringify(spec),
@@ -917,7 +1095,7 @@ var TrainingEngine = class {
917
1095
  }
918
1096
  async poll(job, now) {
919
1097
  const ref = {
920
- addonId: job.request.addonId,
1098
+ entryId: job.request.entryId,
921
1099
  jobKey: job.id
922
1100
  };
923
1101
  const status = await this.ports.provider.getJob(ref);
@@ -947,7 +1125,7 @@ var TrainingEngine = class {
947
1125
  }
948
1126
  async afterPoll(job, status, now) {
949
1127
  const ref = {
950
- addonId: job.request.addonId,
1128
+ entryId: job.request.entryId,
951
1129
  jobKey: job.id
952
1130
  };
953
1131
  const running = status.state === "pending" || status.state === "starting" || status.state === "running";
@@ -983,15 +1161,18 @@ var TrainingEngine = class {
983
1161
  await this.fail(job.id, "provider-error", job.stage, "the provider stopped the job");
984
1162
  return;
985
1163
  }
986
- if (status.state === "lost") await this.mutator.update(job.id, (j) => ({
987
- ...j,
988
- state: "lost"
989
- }));
1164
+ if (status.state === "lost") {
1165
+ await this.mutator.update(job.id, (j) => ({
1166
+ ...j,
1167
+ state: "lost"
1168
+ }));
1169
+ await this.advanceBatchQueue(job.id);
1170
+ }
990
1171
  }
991
1172
  async failureDetail(job) {
992
1173
  try {
993
1174
  const raw = await this.ports.provider.readResult({
994
- addonId: job.request.addonId,
1175
+ entryId: job.request.entryId,
995
1176
  jobKey: job.id
996
1177
  });
997
1178
  if (!raw.found || raw.text === void 0) return null;
@@ -1006,7 +1187,7 @@ var TrainingEngine = class {
1006
1187
  const job = await this.ports.jobs.get(id);
1007
1188
  if (job === null || job.state !== "collecting") return;
1008
1189
  const ref = {
1009
- addonId: job.request.addonId,
1190
+ entryId: job.request.entryId,
1010
1191
  jobKey: id
1011
1192
  };
1012
1193
  try {
@@ -1053,6 +1234,7 @@ var TrainingEngine = class {
1053
1234
  bytes: got.bytes
1054
1235
  } });
1055
1236
  await this.cleanupCloud(id);
1237
+ await this.advanceBatchQueue(id);
1056
1238
  } catch (err) {
1057
1239
  await this.fail(id, "fetch-failed", "package", errText(err));
1058
1240
  }
@@ -1063,6 +1245,7 @@ var TrainingEngine = class {
1063
1245
  state: "cancelled"
1064
1246
  }));
1065
1247
  await this.cleanupCloud(id);
1248
+ await this.advanceBatchQueue(id);
1066
1249
  }
1067
1250
  /** Delete the cloud copy (dataset first). A failure is retried by the next tick. */
1068
1251
  async cleanupCloud(id) {
@@ -1070,7 +1253,7 @@ var TrainingEngine = class {
1070
1253
  if (job === null || job.cloudDatasetDeletedAt !== null) return;
1071
1254
  try {
1072
1255
  const { deleted } = await this.ports.provider.cleanupJob({
1073
- addonId: job.request.addonId,
1256
+ entryId: job.request.entryId,
1074
1257
  jobKey: id
1075
1258
  });
1076
1259
  await this.mutator.update(id, (j) => ({
@@ -1092,7 +1275,7 @@ var TrainingEngine = class {
1092
1275
  async fail(id, reason, stage, detail) {
1093
1276
  let grace = 0;
1094
1277
  const job = await this.ports.jobs.get(id);
1095
- if (job !== null && reason === "trainer-failed") grace = ((await this.ports.provider.describe({})).find((p) => p.addonId === job.request.addonId)?.defaults.failedDatasetGraceHours ?? 0) * 60 * 6e4;
1278
+ if (job !== null && reason === "trainer-failed") grace = ((await this.ports.provider.describe({})).find((p) => p.entryId === job.request.entryId)?.defaults.failedDatasetGraceHours ?? 0) * 60 * 6e4;
1096
1279
  await this.mutator.update(id, (j) => ({
1097
1280
  ...j,
1098
1281
  state: "failed",
@@ -1109,6 +1292,7 @@ var TrainingEngine = class {
1109
1292
  stage,
1110
1293
  detail
1111
1294
  } });
1295
+ await this.advanceBatchQueue(id);
1112
1296
  }
1113
1297
  withStatus(job, status) {
1114
1298
  const mapped = job.state === "cancelling" ? "cancelling" : status.state === "pending" ? "submitted" : status.state === "starting" ? "starting" : status.state === "running" ? "running" : job.state;
@@ -1127,7 +1311,96 @@ var TrainingEngine = class {
1127
1311
  }
1128
1312
  };
1129
1313
  }
1314
+ /**
1315
+ * Blocks a new start from outside while any non-settled job is in flight —
1316
+ * GPU-holding (`ACTIVE`) or a batch sibling still `queued`. Poll/cancel keep
1317
+ * using `ACTIVE` alone (queued holds no GPU).
1318
+ */
1319
+ busyJob(jobs) {
1320
+ return jobs.find((j) => ACTIVE.has(j.state) || j.state === "queued");
1321
+ }
1322
+ newJobRow(args) {
1323
+ const now = this.ports.now();
1324
+ const { corpus, split } = args.accepted;
1325
+ return {
1326
+ id: this.ports.newId(),
1327
+ idempotencyKey: args.idempotencyKey,
1328
+ name: args.name,
1329
+ createdAt: now,
1330
+ updatedAt: now,
1331
+ state: args.state,
1332
+ batchId: args.batchId,
1333
+ stage: null,
1334
+ failure: null,
1335
+ request: args.request,
1336
+ maxCostUsd: args.maxCostUsd,
1337
+ maxRuntimeSec: args.maxRuntimeSec,
1338
+ trainer: this.trainer,
1339
+ datasetLayout: corpus.layout,
1340
+ frameCount: corpus.frameIds.length,
1341
+ frameIds: [...corpus.frameIds],
1342
+ annotationHash: corpus.annotationHash,
1343
+ dataset: null,
1344
+ split,
1345
+ provider: {
1346
+ state: null,
1347
+ providerJobId: null,
1348
+ startedAt: null,
1349
+ endedAt: null,
1350
+ billedSeconds: 0,
1351
+ costUsd: 0,
1352
+ costIsEstimate: true,
1353
+ lastPolledAt: null
1354
+ },
1355
+ progress: {
1356
+ epoch: null,
1357
+ epochs: null,
1358
+ etaSec: null,
1359
+ series: []
1360
+ },
1361
+ logTail: [],
1362
+ logCursor: null,
1363
+ artifacts: null,
1364
+ cloudDatasetDeletedAt: null,
1365
+ cloudCleanupDueAt: null,
1366
+ registeredModelIds: [],
1367
+ canary: null
1368
+ };
1369
+ }
1370
+ /**
1371
+ * When a batch member settles and nothing else is ACTIVE, promote the next
1372
+ * `queued` sibling (same `batchId`, oldest first) into `uploading`.
1373
+ */
1374
+ async advanceBatchQueue(settledId) {
1375
+ const settled = await this.ports.jobs.get(settledId);
1376
+ if (settled === null || settled.batchId === null || !SETTLED.has(settled.state)) return;
1377
+ const jobs = await this.ports.jobs.list();
1378
+ if (jobs.some((j) => ACTIVE.has(j.state))) return;
1379
+ const next = jobs.filter((j) => j.batchId === settled.batchId && j.state === "queued").toSorted((a, b) => a.createdAt - b.createdAt)[0];
1380
+ if (next === void 0) return;
1381
+ const moved = await this.mutator.update(next.id, (j) => j.state === "queued" ? {
1382
+ ...j,
1383
+ state: "uploading"
1384
+ } : j);
1385
+ if (moved === null || moved.state !== "uploading") return;
1386
+ this.ports.logger.info("training batch advanced to next model", { meta: {
1387
+ batchId: settled.batchId,
1388
+ jobId: moved.id,
1389
+ baseModelId: moved.request.baseModelId
1390
+ } });
1391
+ this.runTask(moved.id, () => this.uploadAndSubmit(moved.id));
1392
+ }
1130
1393
  };
1394
+ function uniqueBaseModelIds(ids) {
1395
+ const seen = /* @__PURE__ */ new Set();
1396
+ const out = [];
1397
+ for (const id of ids) {
1398
+ if (seen.has(id)) continue;
1399
+ seen.add(id);
1400
+ out.push(id);
1401
+ }
1402
+ return out;
1403
+ }
1131
1404
  //#endregion
1132
1405
  //#region src/orchestrator/return-flow.ts
1133
1406
  /**
@@ -1170,7 +1443,7 @@ function catalogEntryFromDraft(input) {
1170
1443
  return {
1171
1444
  id: draft.id,
1172
1445
  name: draft.name,
1173
- description: `${draft.description} Fine-tuned from ${base.modelId} by CamStack Training (job ${job.id}, dataset ${job.request.datasetVersionId}).`,
1446
+ description: `${draft.description} Fine-tuned from ${base.modelId} by CamStack Training (job ${job.id}, ${String(job.frameCount)} frames from ${String(job.request.selection.collectionIds.length)} collection(s)).`,
1174
1447
  inputSize: { ...draft.inputSize },
1175
1448
  labels: [],
1176
1449
  preprocessMode: "letterbox",
@@ -1184,7 +1457,7 @@ function catalogEntryFromDraft(input) {
1184
1457
  url: "https://github.com/ultralytics/ultralytics"
1185
1458
  },
1186
1459
  attribution: `© Ultralytics, AGPL-3.0 (YOLOv9 weights); ${result.licence}. Corresponding source: ${TRAINER_SOURCE_URL} at revision ${result.trainer.revision}.`,
1187
- modifications: `fine-tuned on the operator's own frames (dataset ${job.request.datasetVersionId}) and exported by CamStack Training`
1460
+ modifications: `fine-tuned on the operator's own frames (${String(job.frameCount)} frames; collections ${job.request.selection.collectionIds.join(", ")}) and exported by CamStack Training`
1188
1461
  },
1189
1462
  formats
1190
1463
  };
@@ -1341,7 +1614,7 @@ var ReturnFlow = class {
1341
1614
  //#endregion
1342
1615
  //#region src/orchestrator/stores.ts
1343
1616
  /**
1344
- * The orchestrator's two durable tables, both in the settings store.
1617
+ * The orchestrator's durable job table in the settings store.
1345
1618
  *
1346
1619
  * Rows are validated on the way OUT (a row a newer build wrote, or a hand
1347
1620
  * edit, never reaches the state machine as a half-shaped job) and dropped
@@ -1357,13 +1630,6 @@ var ReturnFlow = class {
1357
1630
  * separately (14 days after ready/registration) and the row says so."
1358
1631
  */
1359
1632
  var TRAINING_JOBS_COLLECTION = "training:jobs";
1360
- /**
1361
- * @durable class=ledger owner=training
1362
- * write="one row per dataset version, inserted once (cut by pipeline-analytics, or moved
1363
- * from its local store on first sight of this capability) and never updated."
1364
- * retention="none — rows leave only when the operator deletes the version."
1365
- */
1366
- var TRAINING_DATASET_VERSIONS_COLLECTION = "training:dataset-versions";
1367
1633
  var JOB_COLUMNS = [
1368
1634
  {
1369
1635
  name: "id",
@@ -1400,37 +1666,6 @@ var JOB_INDEXES = [{
1400
1666
  name: "idx_training_jobs_created",
1401
1667
  columns: ["createdAt"]
1402
1668
  }];
1403
- var VERSION_COLUMNS = [
1404
- {
1405
- name: "id",
1406
- type: "TEXT",
1407
- primaryKey: true,
1408
- notNull: true
1409
- },
1410
- {
1411
- name: "nameKey",
1412
- type: "TEXT",
1413
- notNull: true
1414
- },
1415
- {
1416
- name: "createdAt",
1417
- type: "INTEGER",
1418
- notNull: true
1419
- },
1420
- {
1421
- name: "row",
1422
- type: "JSON",
1423
- notNull: true
1424
- }
1425
- ];
1426
- var VERSION_INDEXES = [{
1427
- name: "idx_training_versions_name_key",
1428
- columns: ["nameKey"],
1429
- unique: true
1430
- }, {
1431
- name: "idx_training_versions_created",
1432
- columns: ["createdAt"]
1433
- }];
1434
1669
  function parseJson(value) {
1435
1670
  if (typeof value !== "string") return value;
1436
1671
  try {
@@ -1448,11 +1683,6 @@ async function declareTrainingCollections(store) {
1448
1683
  columns: [...JOB_COLUMNS],
1449
1684
  indexes: [...JOB_INDEXES]
1450
1685
  });
1451
- await store.declareCollection.mutate({
1452
- collection: TRAINING_DATASET_VERSIONS_COLLECTION,
1453
- columns: [...VERSION_COLUMNS],
1454
- indexes: [...VERSION_INDEXES]
1455
- });
1456
1686
  }
1457
1687
  var SettingsJobStore = class {
1458
1688
  store;
@@ -1512,74 +1742,6 @@ var SettingsJobStore = class {
1512
1742
  return null;
1513
1743
  }
1514
1744
  };
1515
- var nameKeyOf = (name) => name.trim().toLowerCase();
1516
- var SettingsVersionStore = class {
1517
- store;
1518
- logger;
1519
- constructor(store, logger) {
1520
- this.store = store;
1521
- this.logger = logger;
1522
- }
1523
- /** Insert once; an existing id is left as it is (a version is immutable). */
1524
- put = async (row) => {
1525
- if (await this.get(row.id) !== null) return false;
1526
- await this.store.set.mutate({
1527
- collection: TRAINING_DATASET_VERSIONS_COLLECTION,
1528
- key: row.id,
1529
- value: {
1530
- nameKey: nameKeyOf(row.name),
1531
- createdAt: row.createdAt,
1532
- row
1533
- }
1534
- });
1535
- return true;
1536
- };
1537
- get = async (id) => {
1538
- const row = await this.store.get.query({
1539
- collection: TRAINING_DATASET_VERSIONS_COLLECTION,
1540
- key: id
1541
- });
1542
- return row === null || row === void 0 ? null : this.parse(id, recordOf(row));
1543
- };
1544
- list = async () => {
1545
- return (await this.store.query.query({
1546
- collection: TRAINING_DATASET_VERSIONS_COLLECTION,
1547
- filter: { orderBy: {
1548
- field: "createdAt",
1549
- direction: "desc"
1550
- } }
1551
- })).flatMap((r) => {
1552
- const parsed = this.parse(r.id, recordOf(r.data));
1553
- return parsed === null ? [] : [parsed];
1554
- });
1555
- };
1556
- findIdByName = async (name) => {
1557
- return (await this.store.query.query({
1558
- collection: "training:dataset-versions",
1559
- filter: {
1560
- where: { nameKey: nameKeyOf(name) },
1561
- limit: 1
1562
- }
1563
- }))[0]?.id ?? null;
1564
- };
1565
- remove = async (id) => {
1566
- if (await this.get(id) === null) return false;
1567
- await this.store.delete.mutate({
1568
- collection: TRAINING_DATASET_VERSIONS_COLLECTION,
1569
- key: id
1570
- });
1571
- return true;
1572
- };
1573
- parse(id, data) {
1574
- const parsed = TrainingDatasetVersionRowSchema.safeParse(parseJson(data["row"]));
1575
- if (parsed.success) return parsed.data;
1576
- this.logger.warn("training dataset version row does not parse — skipped", { meta: {
1577
- versionId: id,
1578
- issue: parsed.error.issues[0]?.message ?? "unknown"
1579
- } });
1580
- return null;
1581
- }
1582
- };
1583
1745
  //#endregion
1584
1746
  //#region src/orchestrator/trainer-source.ts
1585
1747
  var TRAINER_SOURCE = {
@@ -1620,11 +1782,10 @@ var TrainingAddon = class extends BaseAddon {
1620
1782
  if (peer === void 0) throw new Error("training needs the peer-bytes facility");
1621
1783
  await declareTrainingCollections(api.settingsStore);
1622
1784
  const logger = this.ctx.logger;
1623
- const versions = new SettingsVersionStore(api.settingsStore, logger);
1624
1785
  const ports = {
1625
1786
  provider: providerPort(api),
1626
1787
  datasets: datasetPort(api),
1627
- versions,
1788
+ corpus: corpusPort(api),
1628
1789
  jobs: new SettingsJobStore(api.settingsStore, logger),
1629
1790
  artifacts: createDiskArtifacts(path.join(this.ctx.dataDir, "training", "jobs"), peer),
1630
1791
  registry: registryPort(api),
@@ -1637,20 +1798,13 @@ var TrainingAddon = class extends BaseAddon {
1637
1798
  const flow = new ReturnFlow(ports, engine.mutator);
1638
1799
  this.engine = engine;
1639
1800
  const provider = {
1640
- putDatasetVersion: async (row) => ({ stored: await versions.put(row) }),
1641
- getDatasetVersion: async ({ id }) => versions.get(id),
1642
- listDatasetVersions: async () => (await versions.list()).map(({ frameIds, ...rest }) => ({
1643
- ...rest,
1644
- frameCount: frameIds.length
1645
- })),
1646
- findDatasetVersionIdByName: async ({ name }) => ({ id: await versions.findIdByName(name) }),
1647
- removeDatasetVersion: async ({ id }) => ({ removed: await versions.remove(id) }),
1648
1801
  listBaseModels: async () => BASE_MODELS.map(publicBaseModel),
1649
1802
  planJob: async (request) => (await planJob(request, {
1650
- versions,
1803
+ corpus: ports.corpus,
1651
1804
  provider: ports.provider
1652
1805
  })).plan,
1653
1806
  startJob: async (input) => engine.startJob(input),
1807
+ startJobBatch: async (input) => engine.startJobBatch(input),
1654
1808
  listJobs: async () => ports.jobs.list(),
1655
1809
  getJob: async ({ id }) => ports.jobs.get(id),
1656
1810
  cancelJob: async ({ id }) => engine.cancelJob(id),