monday-cli 0.5.0 → 0.7.0

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 (49) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/README.md +209 -35
  3. package/dist/api/column-types.d.ts +81 -19
  4. package/dist/api/column-types.d.ts.map +1 -1
  5. package/dist/api/column-types.js +44 -11
  6. package/dist/api/column-types.js.map +1 -1
  7. package/dist/api/column-values.d.ts +22 -10
  8. package/dist/api/column-values.d.ts.map +1 -1
  9. package/dist/api/column-values.js +50 -20
  10. package/dist/api/column-values.js.map +1 -1
  11. package/dist/api/file-column-set.d.ts +613 -0
  12. package/dist/api/file-column-set.d.ts.map +1 -0
  13. package/dist/api/file-column-set.js +568 -0
  14. package/dist/api/file-column-set.js.map +1 -0
  15. package/dist/api/raw-write.d.ts +38 -17
  16. package/dist/api/raw-write.d.ts.map +1 -1
  17. package/dist/api/raw-write.js +62 -25
  18. package/dist/api/raw-write.js.map +1 -1
  19. package/dist/api/resolver-error-fold.d.ts +25 -0
  20. package/dist/api/resolver-error-fold.d.ts.map +1 -1
  21. package/dist/api/resolver-error-fold.js +56 -0
  22. package/dist/api/resolver-error-fold.js.map +1 -1
  23. package/dist/commands/board/column-create.d.ts +13 -3
  24. package/dist/commands/board/column-create.d.ts.map +1 -1
  25. package/dist/commands/board/column-create.js +27 -8
  26. package/dist/commands/board/column-create.js.map +1 -1
  27. package/dist/commands/item/create.d.ts +24 -8
  28. package/dist/commands/item/create.d.ts.map +1 -1
  29. package/dist/commands/item/create.js +601 -44
  30. package/dist/commands/item/create.js.map +1 -1
  31. package/dist/commands/item/set.d.ts +33 -3
  32. package/dist/commands/item/set.d.ts.map +1 -1
  33. package/dist/commands/item/set.js +193 -15
  34. package/dist/commands/item/set.js.map +1 -1
  35. package/dist/commands/item/update.d.ts +203 -3
  36. package/dist/commands/item/update.d.ts.map +1 -1
  37. package/dist/commands/item/update.js +1015 -68
  38. package/dist/commands/item/update.js.map +1 -1
  39. package/dist/commands/item/upload.d.ts.map +1 -1
  40. package/dist/commands/item/upload.js +16 -69
  41. package/dist/commands/item/upload.js.map +1 -1
  42. package/dist/commands/update/upload.d.ts.map +1 -1
  43. package/dist/commands/update/upload.js +9 -59
  44. package/dist/commands/update/upload.js.map +1 -1
  45. package/dist/utils/file-source.d.ts +93 -0
  46. package/dist/utils/file-source.d.ts.map +1 -0
  47. package/dist/utils/file-source.js +140 -0
  48. package/dist/utils/file-source.js.map +1 -0
  49. package/package.json +1 -1
@@ -52,13 +52,18 @@ import { parseArgv } from '../parse-argv.js';
52
52
  import { ApiError, MondayCliError, UsageError } from '../../utils/errors.js';
53
53
  import { selectMutation, } from '../../api/column-values.js';
54
54
  import { executeItemMutation } from '../../api/item-mutation-execute.js';
55
+ import { executeFileColumnSet, fileColumnSetOutputSchema, preCheckM38FileDispatch, } from '../../api/file-column-set.js';
56
+ import { precheckLocalFile } from '../../utils/file-source.js';
57
+ import { invalidateBoard } from '../../api/cache.js';
58
+ import { dispatchSequential, } from '../../api/partial-success-mutation.js';
59
+ import { dispatchParallel } from '../../api/parallel-dispatch.js';
55
60
  import { parseSetRawExpression, } from '../../api/raw-write.js';
56
61
  import { splitSetExpression } from '../../api/set-expression.js';
57
62
  import { buildResolutionContexts } from '../../api/resolution-context.js';
58
63
  import { resolveBoardId } from '../../api/item-board-lookup.js';
59
- import { SourceAggregator } from '../../api/source-aggregator.js';
64
+ import { SourceAggregator, mergeCacheAge, mergeSource, } from '../../api/source-aggregator.js';
60
65
  import { resolveAndTranslate } from '../../api/resolution-pass.js';
61
- import { foldAndRemap } from '../../api/resolver-error-fold.js';
66
+ import { foldAndRemap, mergeResolverWarningsIntoError, } from '../../api/resolver-error-fold.js';
62
67
  import { planChanges } from '../../api/dry-run.js';
63
68
  import { buildQueryParams } from '../../api/filters.js';
64
69
  import { loadBoardMetadata, refreshBoardMetadata, } from '../../api/board-metadata.js';
@@ -69,7 +74,23 @@ import { resolveMeFactory } from '../../api/item-helpers.js';
69
74
  import { projectedItemSchema, } from '../../api/item-projection.js';
70
75
  import { runPartialSuccessBulkUpdate, buildPartialSuccessBulkSummary, partialSuccessBulkUpdateDataSchema, PARTIAL_SUCCESS_BULK_DISPATCH_SOURCE, } from '../../api/partial-success-bulk.js';
71
76
  import { MIN_CONCURRENCY, MAX_CONCURRENCY, } from '../../api/parallel-dispatch.js';
72
- export const itemUpdateOutputSchema = projectedItemSchema;
77
+ /**
78
+ * Output envelope union — projected-item for the JSON translator
79
+ * path (text / status / dropdown / date / people / etc.) +
80
+ * file-dispatch envelope for the friendly `--set <file-col>=<path>`
81
+ * path. The file-dispatch shape ships across v0.6-M38 (single-item
82
+ * — `operation: 'add_file_to_column'`) and v0.7-M42 (bulk —
83
+ * `operation: 'item_update_bulk_file_set'`; the per-item fan-out's
84
+ * `data.results[i].asset` slots wrap M31's `Asset` projection). The
85
+ * union below admits only the single-item shape because the bulk
86
+ * variant is emitted via its own `bulkFileSetDataSchema` at the
87
+ * `runItemUpdateBulkFileDispatch` helper; agents discriminate on
88
+ * `operation` (present + literal value identifies the variant).
89
+ */
90
+ export const itemUpdateOutputSchema = z.union([
91
+ projectedItemSchema,
92
+ fileColumnSetOutputSchema,
93
+ ]);
73
94
  /**
74
95
  * Input shape — supports both single-item and bulk shapes.
75
96
  *
@@ -256,7 +277,7 @@ export const itemUpdateCommand = {
256
277
  ...(itemId === undefined ? {} : { itemId }),
257
278
  ...opts,
258
279
  });
259
- const { client, globalFlags, apiVersion, toEmit } = resolveClient(ctx, program.opts());
280
+ const { client, globalFlags, apiVersion, multipart, toEmit } = resolveClient(ctx, program.opts());
260
281
  const dispatch = validateInputShape(parsed);
261
282
  if (dispatch.kind === 'bulk') {
262
283
  await runBulk({
@@ -266,6 +287,7 @@ export const itemUpdateCommand = {
266
287
  apiVersion,
267
288
  ctx,
268
289
  programOpts: program.opts(),
290
+ multipart,
269
291
  });
270
292
  return;
271
293
  }
@@ -284,49 +306,150 @@ export const itemUpdateCommand = {
284
306
  explicit: parsed.board,
285
307
  });
286
308
  const { dateResolution, peopleResolution, tagResolution, relationResolution } = buildResolutionContexts({ client, ctx, globalFlags });
287
- if (globalFlags.dryRun) {
288
- const result = await planChanges({
309
+ // v0.6-M38 mutex check at the column-resolution boundary
310
+ // (cli-design §5.3 step 5 "File-column dispatch leg —
311
+ // mutex rules"; D2/D5/D6 closures). Pre-checks setEntries'
312
+ // column types BEFORE calling `planChanges` /
313
+ // `resolveAndTranslate`, so the mutex rules fire upfront
314
+ // independent of translator-order side effects. The
315
+ // pre-check operates on setEntries only — `--set-raw
316
+ // <file-col>=<json>` rejection stays at
317
+ // `translateRawColumnValue` per D3 (permanent rejection;
318
+ // M38 dispatch never hijacks the --set-raw path).
319
+ const m38 = await preCheckM38FileDispatch({
320
+ client,
321
+ boardId,
322
+ setEntries,
323
+ setRawCount: rawEntries.length,
324
+ hasName: parsed.name !== undefined,
325
+ callShape: 'item_update_single',
326
+ env: ctx.env,
327
+ noCache: globalFlags.noCache,
328
+ });
329
+ if (m38.kind === 'file') {
330
+ // M38 dispatch path. The dry-run / live branches emit the
331
+ // D4 envelope / M31-shaped envelope respectively, threading
332
+ // the pre-check's resolver warnings + source aggregation
333
+ // into the final envelope (P3-1 — IMPL round-1 fix).
334
+ await runItemUpdateSingleFileDispatch({
289
335
  client,
336
+ multipart,
337
+ ctx,
338
+ programOpts: program.opts(),
339
+ apiVersion,
290
340
  boardId,
291
341
  itemId: dispatch.itemId,
292
- setEntries,
293
- ...(rawEntries.length === 0 ? {} : { rawEntries }),
294
- ...(parsed.name === undefined ? {} : { nameChange: parsed.name }),
295
- dateResolution,
296
- peopleResolution,
297
- tagResolution,
298
- relationResolution,
299
- env: ctx.env,
300
- noCache: globalFlags.noCache,
342
+ m38,
343
+ isDryRun: globalFlags.dryRun,
344
+ retries: globalFlags.retry,
345
+ toEmit,
301
346
  });
347
+ return;
348
+ }
349
+ if (globalFlags.dryRun) {
350
+ // m38.kind === 'json' — standard JSON translator path
351
+ // applies. The pre-check's resolver warnings + source
352
+ // aggregation seed the downstream planChanges run
353
+ // (downstream resolveAndTranslate hits cache for the
354
+ // already-resolved setEntries; source aggregation is
355
+ // correct per §6.1 — both legs counted).
356
+ let result;
357
+ try {
358
+ result = await planChanges({
359
+ client,
360
+ boardId,
361
+ itemId: dispatch.itemId,
362
+ setEntries,
363
+ ...(rawEntries.length === 0 ? {} : { rawEntries }),
364
+ ...(parsed.name === undefined ? {} : { nameChange: parsed.name }),
365
+ dateResolution,
366
+ peopleResolution,
367
+ tagResolution,
368
+ relationResolution,
369
+ env: ctx.env,
370
+ noCache: globalFlags.noCache,
371
+ });
372
+ }
373
+ catch (err) {
374
+ // Round-3 P3-1 fix: fold M38 pre-check warnings into
375
+ // the failure envelope's `details.resolver_warnings`
376
+ // slot. The pre-check may have emitted
377
+ // `stale_cache_refreshed` / `column_token_collision`;
378
+ // if downstream `planChanges` throws (translator
379
+ // error, archived column, etc.), the error's own
380
+ // fold doesn't include the pre-check leg.
381
+ if (err instanceof MondayCliError && m38.warnings.length > 0) {
382
+ throw mergeResolverWarningsIntoError(err, m38.warnings);
383
+ }
384
+ throw err;
385
+ }
386
+ // Round-2 P3-1 fix: thread the pre-check's resolver
387
+ // warnings into the dry-run envelope. A
388
+ // `stale_cache_refreshed` or `column_token_collision`
389
+ // emitted by the pre-check would otherwise be lost —
390
+ // downstream `planChanges` re-resolves against the
391
+ // now-warm cache and doesn't re-emit `stale_cache_refreshed`
392
+ // (the refresh already ran). Dedupe by code+message+token
393
+ // mirrors `dedupeWarnings` from the bulk path.
394
+ //
395
+ // **Round-3 P3-1 fix (error-path)**: the planChanges
396
+ // call itself is wrapped above (line 483); if it throws
397
+ // before returning, the error catch below folds
398
+ // `m38.warnings` into `details.resolver_warnings` so
399
+ // pre-check warnings ride into the failure envelope
400
+ // too.
302
401
  emitDryRun({
303
402
  ctx,
304
403
  programOpts: program.opts(),
305
404
  plannedChanges: result.plannedChanges,
306
- source: result.source,
307
- cacheAgeSeconds: result.cacheAgeSeconds,
308
- warnings: result.warnings,
405
+ source: mergeSource(m38.source, result.source),
406
+ cacheAgeSeconds: mergeCacheAge(m38.cacheAgeSeconds, result.cacheAgeSeconds),
407
+ warnings: dedupeWarnings([...m38.warnings, ...result.warnings]),
309
408
  apiVersion,
310
409
  });
311
410
  return;
312
411
  }
313
412
  // Live update path — three-pass resolution + translation
314
- // through the shared helper (R20 lift).
315
- const resolutionResult = await resolveAndTranslate({
316
- client,
317
- boardId,
318
- setEntries,
319
- rawEntries,
320
- dateResolution,
321
- peopleResolution,
322
- tagResolution,
323
- relationResolution,
324
- env: ctx.env,
325
- noCache: globalFlags.noCache,
326
- });
327
- const collectedWarnings = [
413
+ // through the shared helper (R20 lift). Pre-check already
414
+ // resolved setEntries (cache hit downstream).
415
+ let resolutionResult;
416
+ try {
417
+ resolutionResult = await resolveAndTranslate({
418
+ client,
419
+ boardId,
420
+ setEntries,
421
+ rawEntries,
422
+ dateResolution,
423
+ peopleResolution,
424
+ tagResolution,
425
+ relationResolution,
426
+ env: ctx.env,
427
+ noCache: globalFlags.noCache,
428
+ ...(m38.source === undefined ? {} : { initialSource: m38.source }),
429
+ initialCacheAgeSeconds: m38.cacheAgeSeconds,
430
+ });
431
+ }
432
+ catch (err) {
433
+ // Round-3 P3-1 fix: fold M38 pre-check warnings into the
434
+ // failure envelope's `details.resolver_warnings` slot. The
435
+ // pre-check may have emitted `stale_cache_refreshed` /
436
+ // `column_token_collision`; if downstream
437
+ // `resolveAndTranslate` throws (translator error, archived
438
+ // column post-cache-warm, etc.), the thrown error's own
439
+ // resolver-warnings fold doesn't include the pre-check leg.
440
+ if (err instanceof MondayCliError && m38.warnings.length > 0) {
441
+ throw mergeResolverWarningsIntoError(err, m38.warnings);
442
+ }
443
+ throw err;
444
+ }
445
+ // Round-2 P3-1 fix: thread pre-check's resolver warnings
446
+ // alongside downstream warnings, deduped by code+message+
447
+ // token — pre-check's `stale_cache_refreshed` would
448
+ // otherwise be lost (warm cache suppresses re-emit).
449
+ const collectedWarnings = dedupeWarnings([
450
+ ...m38.warnings,
328
451
  ...resolutionResult.warnings,
329
- ];
452
+ ]);
330
453
  const resolvedIds = resolutionResult.resolvedIds;
331
454
  const sourceAgg = new SourceAggregator();
332
455
  if (resolutionResult.source !== undefined) {
@@ -489,7 +612,7 @@ const bulkLiveDataSchema = z.object({
489
612
  * `--concurrency` flag is the future extension point.
490
613
  */
491
614
  const runBulk = async (inputs) => {
492
- const { parsed, client, globalFlags, apiVersion, ctx, programOpts } = inputs;
615
+ const { parsed, client, globalFlags, apiVersion, ctx, programOpts, multipart } = inputs;
493
616
  /* c8 ignore next 6 — defensive: validateInputShape guarantees
494
617
  parsed.board is non-undefined when shape is bulk; the type
495
618
  guard exists for TS. */
@@ -514,6 +637,63 @@ const runBulk = async (inputs) => {
514
637
  env: ctx.env,
515
638
  noCache: globalFlags.noCache,
516
639
  });
640
+ // 1.5) File-column dispatch pre-check at the column-resolution
641
+ // boundary, BEFORE the items_page walker + confirmation
642
+ // gate. The pre-check resolves setEntries against the now-
643
+ // warm metadata cache and runs
644
+ // `enforceSingleFileColumnSet({callShape: 'item_update_bulk'})`:
645
+ //
646
+ // - Multi-file `--set` → throws `'multi_file_set_unsupported'`
647
+ // (universal rule; single-column-per-wire-call).
648
+ // - File `--set` + value `--set` / `--set-raw` / `--name`
649
+ // → throws `'mixed_file_and_value_sets'` (universal rule;
650
+ // mixing forces non-atomic multi-leg dispatch).
651
+ // - Clean single file `--set` → returns
652
+ // `kind: 'file_bulk'` (v0.7-M42 D5 carve-out fold;
653
+ // action body branches into the per-item multipart
654
+ // fan-out helper `runItemUpdateBulkFileDispatch` —
655
+ // shipped at v0.7-M42 IMPL).
656
+ // - No file `--set` → returns `kind: 'json'` (standard
657
+ // JSON-translator path continues).
658
+ //
659
+ // At v0.6-M38 the `'item_update_bulk'` callShape rejected
660
+ // ALL bulk file `--set` paths with
661
+ // `'file_set_on_bulk_unsupported'`; v0.7-M42 pre-flight
662
+ // contract diff (`160330b`) carved that out per D5 fold,
663
+ // and IMPL (`22df2fa` + R1 fix-up `968b154`) shipped the
664
+ // runtime body. `--set-raw <file-col>=<json>` stays at
665
+ // `translateRawColumnValue`'s D3 permanent rejection (the
666
+ // pre-check only inspects setEntries; the standard path's
667
+ // `translateRawColumnValue` handles --set-raw rejection
668
+ // unchanged).
669
+ let m38Warnings = [];
670
+ let m38FileBulk;
671
+ if (setEntries.length > 0) {
672
+ const m38 = await preCheckM38FileDispatch({
673
+ client,
674
+ boardId,
675
+ setEntries,
676
+ setRawCount: rawEntries.length,
677
+ hasName: parsed.name !== undefined,
678
+ callShape: 'item_update_bulk',
679
+ env: ctx.env,
680
+ noCache: globalFlags.noCache,
681
+ });
682
+ // Round-2 P3-1 fix carry-forward: capture pre-check warnings
683
+ // to thread them into the final envelope. Pre-check resolution
684
+ // warnings (column_token_collision / stale_cache_refreshed)
685
+ // survive even though downstream cache hits suppress
686
+ // re-emission.
687
+ m38Warnings = m38.warnings;
688
+ if (m38.kind === 'file_bulk') {
689
+ // v0.7-M42 D5 carve-out fold. Hold the file_bulk slot for
690
+ // the items_page-walked dispatch leg below; the items_page
691
+ // walker still runs (collects target item IDs) + the
692
+ // confirmation gate still applies + the dispatch loop fans
693
+ // `executeFileColumnSet` across matched items.
694
+ m38FileBulk = m38;
695
+ }
696
+ }
517
697
  const onColumnNotFound = meta.source === 'cache'
518
698
  ? async () => {
519
699
  const refreshed = await refreshBoardMetadata({
@@ -576,6 +756,13 @@ const runBulk = async (inputs) => {
576
756
  // aggregate to `mixed` when metadata was cache-served.
577
757
  const emptyEnvelopeSource = meta.source === 'cache' ? 'mixed' : 'live';
578
758
  if (matchedItemIds.length === 0) {
759
+ // Round-2 P3-1 fix: thread M38 pre-check warnings into the
760
+ // empty-match envelope so a pre-check `stale_cache_refreshed`
761
+ // / `column_token_collision` survives the no-op short-circuit.
762
+ const emptyWarnings = dedupeWarnings([
763
+ ...filterResult.warnings,
764
+ ...m38Warnings,
765
+ ]);
579
766
  if (globalFlags.dryRun) {
580
767
  emitDryRun({
581
768
  ctx,
@@ -583,7 +770,7 @@ const runBulk = async (inputs) => {
583
770
  plannedChanges: [],
584
771
  source: emptyEnvelopeSource,
585
772
  cacheAgeSeconds: meta.cacheAgeSeconds,
586
- warnings: filterResult.warnings,
773
+ warnings: emptyWarnings,
587
774
  apiVersion,
588
775
  });
589
776
  return;
@@ -596,7 +783,7 @@ const runBulk = async (inputs) => {
596
783
  },
597
784
  schema: bulkLiveDataSchema,
598
785
  programOpts,
599
- warnings: filterResult.warnings,
786
+ warnings: emptyWarnings,
600
787
  source: emptyEnvelopeSource,
601
788
  cacheAgeSeconds: meta.cacheAgeSeconds,
602
789
  apiVersion,
@@ -627,6 +814,40 @@ const runBulk = async (inputs) => {
627
814
  // fail-fast invariant. (See step 0 above — moving the parse there
628
815
  // means a malformed --set / --set-raw doesn't pay for the metadata
629
816
  // load + items_page walk first.)
817
+ // v0.7-M42 D5 carve-out fold — bulk file `--set` dispatch leg.
818
+ // When the pre-check returned `kind: 'file_bulk'`, branch into
819
+ // the per-item multipart fan-out helper here. The branch fires
820
+ // AFTER the items_page walker + confirmation gate (so an agent
821
+ // sees the matched-count via `confirmation_required` before
822
+ // the bulk file dispatch fans out) and BEFORE the JSON
823
+ // translator path's `resolveAndTranslate` call (which has
824
+ // nothing to translate when every `--set` is a file column).
825
+ //
826
+ // The helper runs single upfront `precheckLocalFile` + per-item
827
+ // `executeFileColumnSet` (fail-fast vs `--continue-on-error`
828
+ // per `parsed.continueOnError`; sequential vs parallel per
829
+ // `parsed.concurrency`) + post-dispatch `invalidateBoard` +
830
+ // envelope emit (`operation: 'item_update_bulk_file_set'`).
831
+ if (m38FileBulk !== undefined) {
832
+ await runItemUpdateBulkFileDispatch({
833
+ parsed,
834
+ client,
835
+ multipart,
836
+ ctx,
837
+ programOpts,
838
+ apiVersion,
839
+ boardId,
840
+ matchedItemIds,
841
+ m38: m38FileBulk,
842
+ metaSource: meta.source,
843
+ metaCacheAgeSeconds: meta.cacheAgeSeconds,
844
+ filterWarnings: filterResult.warnings,
845
+ retries: globalFlags.retry,
846
+ isDryRun: globalFlags.dryRun,
847
+ noCache: globalFlags.noCache,
848
+ });
849
+ return;
850
+ }
630
851
  const { dateResolution, peopleResolution, tagResolution, relationResolution } = buildResolutionContexts({ client, ctx, globalFlags });
631
852
  // 5) Dry-run path: per-item planChanges. Column resolution is
632
853
  // cached after the first call; per-item state read fires per
@@ -639,26 +860,53 @@ const runBulk = async (inputs) => {
639
860
  // the resolver-warning preservation pattern is meant to keep.
640
861
  if (globalFlags.dryRun) {
641
862
  const allPlanned = [];
642
- const aggregatedWarnings = [...filterResult.warnings];
863
+ // Round-2 P3-1 fix: seed `aggregatedWarnings` with pre-check
864
+ // warnings so `stale_cache_refreshed` survives even though
865
+ // downstream per-item planChanges cache-hits suppress
866
+ // re-emission.
867
+ const aggregatedWarnings = [
868
+ ...filterResult.warnings,
869
+ ...m38Warnings,
870
+ ];
643
871
  const sourceAgg = new SourceAggregator({
644
872
  source: meta.source,
645
873
  cacheAgeSeconds: meta.cacheAgeSeconds,
646
874
  });
647
875
  for (const itemId of matchedItemIds) {
648
- const result = await planChanges({
649
- client,
650
- boardId,
651
- itemId,
652
- setEntries,
653
- ...(rawEntries.length === 0 ? {} : { rawEntries }),
654
- ...(parsed.name === undefined ? {} : { nameChange: parsed.name }),
655
- dateResolution,
656
- peopleResolution,
657
- tagResolution,
658
- relationResolution,
659
- env: ctx.env,
660
- noCache: globalFlags.noCache,
661
- });
876
+ // v0.7-M42 IMPL: clean bulk file `--set` paths branched into
877
+ // `runItemUpdateBulkFileDispatch` above (the `m38FileBulk !==
878
+ // undefined` arm) and returned before reaching this loop, so
879
+ // this dry-run body only ever sees JSON-shaped paths.
880
+ // `--set-raw <file-col>=<json>` still rejects normally via
881
+ // `translateRawColumnValue`'s D3 permanent rejection.
882
+ let result;
883
+ try {
884
+ result = await planChanges({
885
+ client,
886
+ boardId,
887
+ itemId,
888
+ setEntries,
889
+ ...(rawEntries.length === 0 ? {} : { rawEntries }),
890
+ ...(parsed.name === undefined ? {} : { nameChange: parsed.name }),
891
+ dateResolution,
892
+ peopleResolution,
893
+ tagResolution,
894
+ relationResolution,
895
+ env: ctx.env,
896
+ noCache: globalFlags.noCache,
897
+ });
898
+ }
899
+ catch (err) {
900
+ // Round-3 P3-1 fix: fold M38 pre-check warnings into the
901
+ // per-item failure envelope so a pre-check
902
+ // `stale_cache_refreshed` rides into the error's
903
+ // `details.resolver_warnings` even when the per-item
904
+ // planChanges throws.
905
+ if (err instanceof MondayCliError && m38Warnings.length > 0) {
906
+ throw mergeResolverWarningsIntoError(err, m38Warnings);
907
+ }
908
+ throw err;
909
+ }
662
910
  for (const plan of result.plannedChanges) {
663
911
  allPlanned.push(plan);
664
912
  }
@@ -703,25 +951,54 @@ const runBulk = async (inputs) => {
703
951
  // is the narrowed subset used by foldResolverWarningsIntoError —
704
952
  // the helper's contract is to fold collision / stale_cache_refreshed
705
953
  // signals, not generic Warning types.
706
- const resolutionResult = await resolveAndTranslate({
707
- client,
708
- boardId,
709
- setEntries,
710
- rawEntries,
711
- dateResolution,
712
- peopleResolution,
713
- tagResolution,
714
- relationResolution,
715
- env: ctx.env,
716
- noCache: globalFlags.noCache,
717
- initialSource: meta.source,
718
- initialCacheAgeSeconds: meta.cacheAgeSeconds,
719
- });
720
- const collectedWarnings = [
954
+ // v0.6-M38 D5 file-set rejection already fired at the pre-check
955
+ // above (step 1.5). resolveAndTranslate processes only non-file
956
+ // setEntries here.
957
+ let resolutionResult;
958
+ try {
959
+ resolutionResult = await resolveAndTranslate({
960
+ client,
961
+ boardId,
962
+ setEntries,
963
+ rawEntries,
964
+ dateResolution,
965
+ peopleResolution,
966
+ tagResolution,
967
+ relationResolution,
968
+ env: ctx.env,
969
+ noCache: globalFlags.noCache,
970
+ initialSource: meta.source,
971
+ initialCacheAgeSeconds: meta.cacheAgeSeconds,
972
+ });
973
+ }
974
+ catch (err) {
975
+ // Round-3 P3-1 fix: fold M38 pre-check warnings into the
976
+ // bulk-live failure envelope's `details.resolver_warnings`.
977
+ if (err instanceof MondayCliError && m38Warnings.length > 0) {
978
+ throw mergeResolverWarningsIntoError(err, m38Warnings);
979
+ }
980
+ throw err;
981
+ }
982
+ // Round-2 P3-1 fix: include M38 pre-check warnings in the live
983
+ // bulk envelope's aggregated warnings. Pre-check warnings are
984
+ // deduped against downstream resolveAndTranslate warnings so
985
+ // `stale_cache_refreshed` surfaces exactly once.
986
+ const collectedWarnings = dedupeWarnings([
721
987
  ...filterResult.warnings,
988
+ ...m38Warnings,
722
989
  ...resolutionResult.warnings,
723
- ];
724
- const resolverWarnings = [...resolutionResult.warnings];
990
+ ]);
991
+ // Round-3 P3-2 fix: include M38 pre-check warnings in
992
+ // `resolverWarnings`. This is the slot threaded into
993
+ // `foldAndRemap` for fail-fast bulk errors + partial-success
994
+ // results — without the pre-check leg, a pre-check
995
+ // `stale_cache_refreshed` is absent from per-item failure
996
+ // envelopes (and per-item partial-success records' error
997
+ // details).
998
+ const resolverWarnings = dedupeWarnings([
999
+ ...m38Warnings,
1000
+ ...resolutionResult.warnings,
1001
+ ]);
725
1002
  const resolvedIds = resolutionResult.resolvedIds;
726
1003
  // resolveAndTranslate was seeded with meta.source / meta.cacheAge
727
1004
  // above, so resolutionResult.source is always defined post-helper.
@@ -932,4 +1209,674 @@ const runBulk = async (inputs) => {
932
1209
  resolvedIds,
933
1210
  });
934
1211
  };
1212
+ const runItemUpdateSingleFileDispatch = async (inputs) => {
1213
+ const precheck = await precheckLocalFile(inputs.m38.rawValue);
1214
+ if (inputs.isDryRun) {
1215
+ emitDryRun({
1216
+ ctx: inputs.ctx,
1217
+ programOpts: inputs.programOpts,
1218
+ plannedChanges: [
1219
+ {
1220
+ operation: 'add_file_to_column',
1221
+ item_id: inputs.itemId,
1222
+ column_id: inputs.m38.columnId,
1223
+ file_path: inputs.m38.rawValue,
1224
+ filename: precheck.filename,
1225
+ file_size_bytes: precheck.fileSizeBytes,
1226
+ },
1227
+ ],
1228
+ source: 'none',
1229
+ cacheAgeSeconds: null,
1230
+ warnings: inputs.m38.warnings,
1231
+ apiVersion: inputs.apiVersion,
1232
+ });
1233
+ return;
1234
+ }
1235
+ const result = await executeFileColumnSet({
1236
+ client: inputs.client,
1237
+ multipart: inputs.multipart,
1238
+ itemId: inputs.itemId,
1239
+ entry: {
1240
+ columnId: inputs.m38.columnId,
1241
+ columnType: 'file',
1242
+ rawValue: inputs.m38.rawValue,
1243
+ filePath: precheck.filePath,
1244
+ filename: precheck.filename,
1245
+ fileSizeBytes: precheck.fileSizeBytes,
1246
+ },
1247
+ signal: inputs.ctx.signal,
1248
+ retries: inputs.retries,
1249
+ });
1250
+ // §8 single-leg cache invalidation BEFORE emit (mirrors M31).
1251
+ await invalidateBoard(inputs.boardId, inputs.ctx.env);
1252
+ const data = {
1253
+ operation: 'add_file_to_column',
1254
+ item_id: inputs.itemId,
1255
+ column_id: inputs.m38.columnId,
1256
+ filename: precheck.filename,
1257
+ file_size_bytes: precheck.fileSizeBytes,
1258
+ asset: result.asset,
1259
+ };
1260
+ emitMutation({
1261
+ ctx: inputs.ctx,
1262
+ data,
1263
+ schema: fileColumnSetOutputSchema,
1264
+ programOpts: inputs.programOpts,
1265
+ warnings: inputs.m38.warnings.map((w) => ({
1266
+ code: w.code,
1267
+ message: w.message,
1268
+ details: w.details,
1269
+ })),
1270
+ ...inputs.toEmit({
1271
+ data: result.asset,
1272
+ complexity: result.complexity,
1273
+ stats: { attempts: 1, totalBackoffMs: 0 },
1274
+ }),
1275
+ source: 'live',
1276
+ cacheAgeSeconds: null,
1277
+ complexity: result.complexity,
1278
+ resolvedIds: { [inputs.m38.token]: inputs.m38.columnId },
1279
+ });
1280
+ };
1281
+ // ============================================================
1282
+ // v0.7-M42 bulk file `--set` carve-out fold (D5 closure from
1283
+ // v0.6-M38). Per-item multipart fan-out over the `--where`-resolved
1284
+ // item-id set, reusing v0.4-M31's `executeFileColumnSet` runtime
1285
+ // body verbatim under v0.4-M30's `dispatchParallel` /
1286
+ // `dispatchSequential` selector for `--concurrency` semantics.
1287
+ //
1288
+ // **Status: runtime body shipped at v0.7-M42 IMPL.** The pre-flight
1289
+ // contract diff (commit `160330b`) shipped the argv + pre-check +
1290
+ // items_page + confirmation-gate surface plus the per-item dispatch
1291
+ // stub that threw `internal_error` with `details.reason:
1292
+ // 'm42_preflight_stub'`; IMPL replaces the stub with the runtime
1293
+ // body below. The `'m42_preflight_stub'` literal is RESERVED across
1294
+ // the codebase (regression-guarded by an integration test that
1295
+ // asserts the literal does not appear in stdout/stderr); a
1296
+ // historical `'file_set_on_bulk_unsupported'` literal stays
1297
+ // reserved alongside it.
1298
+ //
1299
+ // **D-list closures (v0.7-plan §3 M42 entry):**
1300
+ //
1301
+ // - **D1 — `--concurrency` semantics for file dispatch.** Reuse
1302
+ // v0.4-M30's `dispatchParallel` over a shared
1303
+ // `MultipartTransport`. Each parallel worker constructs its
1304
+ // own `MultipartTransportRequest` per call; the transport
1305
+ // itself is connection-pool-shared per-token. Closes by
1306
+ // inheritance from M30 (concurrency probe pinned at
1307
+ // `scripts/probe/m30-concurrency.report.txt`) + M31 (multipart
1308
+ // wire pinned at `scripts/probe/m31-asset-upload.report.txt`).
1309
+ // No new probe required.
1310
+ //
1311
+ // - **D2 — Per-item asset slot in envelope.** Per-item
1312
+ // `data.results[i].asset: { id, name, ... }` echo on success
1313
+ // (mirrors M31's `itemUploadOutputSchema`'s `asset` slot);
1314
+ // per-item failure surfaces as
1315
+ // `data.results[i].error: { code, message }` per M25
1316
+ // partial-success. Aggregate `data.summary.{matched_count,
1317
+ // applied_count, failed_count, board_id, column_id, filename,
1318
+ // file_size_bytes}` extends M25's
1319
+ // `partialSuccessBulkUpdateDataSchema` with file-dispatch slots
1320
+ // so an agent reading the envelope sees which file was
1321
+ // dispatched + where.
1322
+ //
1323
+ // - **D3 — Per-item file pre-check timing.** Single upfront
1324
+ // `precheckLocalFile` call BEFORE the per-item dispatch loop —
1325
+ // the bulk shape has ONE file path (the value of the file
1326
+ // `--set`) shared across N matched items. A failed pre-check
1327
+ // surfaces upfront as `usage_error` with `details.reason:
1328
+ // 'file_not_readable'` / `'file_empty'` (mirrors M31 single-
1329
+ // item shape) — this is whole-call-abort regardless of
1330
+ // `--continue-on-error`, per cli-design §5.8's "pre-checks
1331
+ // MUST fire BEFORE any wire round-trip" atomicity discipline.
1332
+ // The `--continue-on-error` flag partitions ONLY the wire-
1333
+ // dispatch failures (per-item `add_file_to_column` rejections
1334
+ // from Monday), never the local file pre-check.
1335
+ //
1336
+ // - **D4 — ERROR_CODES delta.** Zero. Registry stays at 29.
1337
+ // Per-item dispatch failures route through the existing
1338
+ // `m25-shaped` per-record `error: { code, message }` shape
1339
+ // under `--continue-on-error`, or through the v0.1 fail-fast
1340
+ // decoration (`details.applied_to` + `applied_count` +
1341
+ // `failed_at_item` + `matched_count`) on the default path;
1342
+ // no new top-level code surfaces.
1343
+ //
1344
+ // **R-class watch-items.**
1345
+ //
1346
+ // - R-v0.6-NEW-1 (file pre-check + Blob-construction helper) —
1347
+ // `precheckLocalFile` consumer count goes 3 → 4 at IMPL (M31
1348
+ // item upload + M31 update upload + M38 item set / item update
1349
+ // single + M42 bulk item update). Already-shipped helper
1350
+ // scales cleanly to consumer 4; graduation candidate at the
1351
+ // 5th consumer (v0.7-M43 create-time fold likely tips it).
1352
+ // - R-v0.6-NEW-2 (`details.reason` discriminator pattern) — 4
1353
+ // instances at v0.6-M38; M42 IMPL adds zero new reasons (per
1354
+ // D4 zero-delta closure — the existing `'file_not_readable'`
1355
+ // / `'file_empty'` / `'multi_file_set_unsupported'` /
1356
+ // `'mixed_file_and_value_sets'` reasons cover every M42
1357
+ // rejection shape).
1358
+ // - R-NEW-76 (parseArgv-BEFORE-c8) — applied at pre-flight; the
1359
+ // c8-ignore boundary dropped at IMPL (runtime body fully
1360
+ // covered).
1361
+ // - R-NEW-72 (post-fix-up cross-doc grep) — apply at every
1362
+ // Codex IMPL fix-up round that flips a contract surface.
1363
+ //
1364
+ // **Future lift candidate.** The fail-fast error-decoration block
1365
+ // (`if (err.code === 'usage_error') { throw new UsageError(...) } else
1366
+ // { throw new ApiError(...) }`) is byte-equivalent across this
1367
+ // helper and the JSON-bulk action body (R-NEW-58 2-consumer
1368
+ // trigger). Lift candidate fires at the 3rd consumer (M43 create-
1369
+ // time fold may add one if its rollback / orphan-warn shape
1370
+ // re-uses the same decoration).
1371
+ // ============================================================
1372
+ /**
1373
+ * Per-item dispatch result for v0.7-M42 bulk file `--set` carve-out
1374
+ * fold. Mirrors the M25 `partialSuccessBulkUpdateResultSchema` shape
1375
+ * with the file-dispatch's `asset` slot replacing the JSON path's
1376
+ * `item` projection:
1377
+ *
1378
+ * - Success: `{ item_id, ok: true, asset: { id, name, ... } }`
1379
+ * - Failure: `{ item_id, ok: false, error: { code, message } }`
1380
+ *
1381
+ * Schema landed at the v0.7-M42 pre-flight contract diff
1382
+ * (`160330b`); runtime body shipped at v0.7-M42 IMPL (`22df2fa`)
1383
+ * + R1 fix-up (`968b154`).
1384
+ */
1385
+ export const bulkFileSetResultSchema = z.object({
1386
+ item_id: z.string().min(1),
1387
+ ok: z.boolean(),
1388
+ asset: z
1389
+ .object({
1390
+ id: z.string().min(1),
1391
+ name: z.string().min(1),
1392
+ })
1393
+ .loose()
1394
+ .optional(),
1395
+ error: z
1396
+ .object({
1397
+ code: z.string().min(1),
1398
+ message: z.string().min(1),
1399
+ })
1400
+ .optional(),
1401
+ });
1402
+ /**
1403
+ * Output `data` shape for the v0.7-M42 bulk file `--set` envelope.
1404
+ * Mirrors M25's `partialSuccessBulkUpdateDataSchema` structure —
1405
+ * `operation: 'item_update_bulk_file_set'` literal discriminator
1406
+ * + `summary.{matched_count, applied_count, failed_count,
1407
+ * board_id}` aggregate slots + per-item `results[]` array.
1408
+ *
1409
+ * Invariant: `matched_count === applied_count + failed_count` for
1410
+ * every emitted envelope (mirrors M25's invariant).
1411
+ */
1412
+ export const bulkFileSetDataSchema = z.object({
1413
+ operation: z.literal('item_update_bulk_file_set'),
1414
+ summary: z.object({
1415
+ matched_count: z.number().int().nonnegative(),
1416
+ applied_count: z.number().int().nonnegative(),
1417
+ failed_count: z.number().int().nonnegative(),
1418
+ board_id: z.string().min(1),
1419
+ column_id: z.string().min(1),
1420
+ filename: z.string().min(1),
1421
+ file_size_bytes: z.number().int().nonnegative(),
1422
+ }),
1423
+ results: z.array(bulkFileSetResultSchema),
1424
+ });
1425
+ /**
1426
+ * Bulk file `--set` per-item dispatch helper (v0.7-M42 D5 carve-out
1427
+ * fold).
1428
+ *
1429
+ * **Status: runtime body shipped at v0.7-M42 IMPL.** argv parse,
1430
+ * shape validation, board-metadata load, pre-check (which returned
1431
+ * `kind: 'file_bulk'` for callers reaching this helper), items_page
1432
+ * walk, and the confirmation gate run upstream — this helper takes
1433
+ * the resolved file-column dispatch slot + matched item-IDs and
1434
+ * fans the multipart wire across them under the partial-success vs
1435
+ * fail-fast contract.
1436
+ *
1437
+ * **Execution shape:**
1438
+ *
1439
+ * 1. Single upfront `precheckLocalFile(inputs.m38.rawValue)` —
1440
+ * one file path shared across all matched items. Failure
1441
+ * surfaces as `usage_error` (`file_not_readable` /
1442
+ * `file_empty`) whole-call-abort regardless of
1443
+ * `--continue-on-error` per D3 + cli-design §5.8 atomicity
1444
+ * discipline (pre-checks MUST fire BEFORE any wire round-trip).
1445
+ * 2. Dry-run branch — emits the D4-shaped envelope with one
1446
+ * `add_file_to_column` planned_change per matched item-ID
1447
+ * (no file bytes loaded; no multipart wire). Unlike M38
1448
+ * single-item which pins `source: 'none'` (its dry-run is
1449
+ * pure-local), bulk dry-run carries the upstream legs'
1450
+ * aggregated `source` (metadata load + items_page walk —
1451
+ * `mixed` when metadata was cache-served, `live` otherwise);
1452
+ * reaching this branch already paid for those wire legs.
1453
+ * 3. Live dispatch — two shapes per `parsed.continueOnError`:
1454
+ * - **Fail-fast (default)** — sequential loop over matched
1455
+ * items (no `--concurrency` per M30 D2 closure:
1456
+ * `--concurrency requires --continue-on-error`); first
1457
+ * per-item failure aborts whole-call with the v0.1-shaped
1458
+ * `details.applied_to` / `applied_count` / `failed_at_item`
1459
+ * / `matched_count` decoration so agents see how many
1460
+ * items applied before the failure.
1461
+ * - **`--continue-on-error`** — routes through
1462
+ * {@link dispatchSequential} (concurrency `undefined`/`1`)
1463
+ * or {@link dispatchParallel} (concurrency `> 1`) over a
1464
+ * shared {@link MultipartTransport}; per-item failures
1465
+ * land as `data.results[i].error: {code, message}` records,
1466
+ * successes carry `data.results[i].asset` via a side-map
1467
+ * fold keyed by `item_id`. `internal_error` re-throws
1468
+ * whole-call via the shared dispatchers' escape hatch
1469
+ * (M14 round-2 F1 precedent) so schema drift surfaces as
1470
+ * top-level `ok: false` rather than per-record.
1471
+ * 4. Cache invalidation — single `invalidateBoard(boardId, env)`
1472
+ * after the dispatch loop completes (mirrors M38's single-
1473
+ * leg invalidate timing; one board covers every matched
1474
+ * item's mutated `asset` slot).
1475
+ * 5. Envelope emit — `data: BulkFileSetData` with the
1476
+ * `operation: 'item_update_bulk_file_set'` literal
1477
+ * discriminator + per-item `results[]` + aggregate
1478
+ * `summary.{matched_count, applied_count, failed_count,
1479
+ * board_id, column_id, filename, file_size_bytes}`. Warnings
1480
+ * threaded as `dedupeWarnings([...filterWarnings,
1481
+ * ...m38.warnings])` (mirrors the JSON-bulk path); source
1482
+ * derives from `metaSource` (cache-served metadata + live
1483
+ * wire calls → `mixed`).
1484
+ */
1485
+ export const runItemUpdateBulkFileDispatch = async (inputs) => {
1486
+ // 1) Single upfront pre-check (D3 closure). Whole-call abort
1487
+ // regardless of `--continue-on-error` per cli-design §5.8 —
1488
+ // the local file pre-check is shared across N matched items,
1489
+ // so failure aborts the whole call before any multipart wire
1490
+ // leg fires. `precheckLocalFile` throws `usage_error` with
1491
+ // `details.reason: 'file_not_readable'` / `'file_empty'`
1492
+ // (M31 single-item discriminators reused per D4 zero-delta
1493
+ // closure).
1494
+ const precheck = await precheckLocalFile(inputs.m38.rawValue);
1495
+ // Combined warnings list threaded into every envelope emit below.
1496
+ // Mirrors the JSON-bulk `dedupeWarnings(filter ∪ m38)` pattern at
1497
+ // the action body; key is `code+message+token` so a pre-check
1498
+ // `stale_cache_refreshed` plus a filter-time `stale_cache_refreshed`
1499
+ // for the SAME token collapse to one entry.
1500
+ const combinedWarnings = dedupeWarnings([
1501
+ ...inputs.filterWarnings,
1502
+ ...inputs.m38.warnings,
1503
+ ]);
1504
+ // Source/cache-age aggregator. Seeded with the metadata leg, then
1505
+ // folds the M38 pre-check leg + a synthetic 'live' leg representing
1506
+ // the items_page walker (always live, fired upstream before this
1507
+ // helper). On dry-run the dispatch leg never fires; on live the
1508
+ // dispatch leg adds another 'live' record (idempotent under
1509
+ // `mergeSource` since 'live' + 'live' = 'live'). Codex IMPL R1
1510
+ // P2-1 fix — pre-fix the helper dropped `inputs.m38.source` +
1511
+ // `inputs.m38.cacheAgeSeconds`, so a cache-served file-column
1512
+ // resolution after a live metadata fetch surfaced `'live'`
1513
+ // instead of `'mixed'`. Mirrors the JSON-bulk path's
1514
+ // SourceAggregator pattern at runBulk's dry-run + live legs.
1515
+ const sourceAgg = new SourceAggregator({
1516
+ source: inputs.metaSource,
1517
+ cacheAgeSeconds: inputs.metaCacheAgeSeconds,
1518
+ });
1519
+ if (inputs.m38.source !== undefined) {
1520
+ sourceAgg.record(inputs.m38.source, inputs.m38.cacheAgeSeconds);
1521
+ }
1522
+ // items_page walker always fires live before reaching the helper
1523
+ // (the empty-match short-circuit is the only path that skips it,
1524
+ // and that path emits the envelope upstream — this helper never
1525
+ // sees matchedItemIds.length === 0). Record one synthetic 'live'
1526
+ // leg so the aggregate reflects the wire round-trip cost the
1527
+ // caller already paid.
1528
+ sourceAgg.record('live', null);
1529
+ // 2) Dry-run branch — D4-shaped envelope. One
1530
+ // `add_file_to_column` planned_change per matched item-ID;
1531
+ // no file bytes loaded, no multipart wire round-trip. Unlike
1532
+ // the M38 single-item dry-run (which pins `source: 'none'`
1533
+ // because its dry-run is pure-local), bulk dry-run carries
1534
+ // the aggregated upstream `source` — metadata load + items_page
1535
+ // walk + M38 pre-check already paid for wire legs to reach
1536
+ // here.
1537
+ if (inputs.isDryRun) {
1538
+ const plannedChanges = inputs.matchedItemIds.map((itemId) => ({
1539
+ operation: 'add_file_to_column',
1540
+ item_id: itemId,
1541
+ column_id: inputs.m38.columnId,
1542
+ file_path: inputs.m38.rawValue,
1543
+ filename: precheck.filename,
1544
+ file_size_bytes: precheck.fileSizeBytes,
1545
+ }));
1546
+ const dryRunAgg = sourceAgg.result();
1547
+ emitDryRun({
1548
+ ctx: inputs.ctx,
1549
+ programOpts: inputs.programOpts,
1550
+ plannedChanges,
1551
+ source: dryRunAgg.source,
1552
+ cacheAgeSeconds: dryRunAgg.cacheAgeSeconds,
1553
+ warnings: combinedWarnings,
1554
+ apiVersion: inputs.apiVersion,
1555
+ });
1556
+ return;
1557
+ }
1558
+ // 3) Live dispatch. Build the shared `FileColumnSetEntry` once —
1559
+ // every per-item leg uses the SAME local file (one path × N
1560
+ // items), so `buildBlobFromPath` (inside `executeFileColumnSet`)
1561
+ // re-reads the bytes per leg rather than sharing one Blob
1562
+ // instance. Re-reading per-leg is the simpler shape; a future
1563
+ // optimisation could memoise the bytes if profiling motivates
1564
+ // it (~bytes × N reads vs ~bytes × 1 read + held in memory).
1565
+ const entry = {
1566
+ columnId: inputs.m38.columnId,
1567
+ columnType: 'file',
1568
+ rawValue: inputs.m38.rawValue,
1569
+ filePath: precheck.filePath,
1570
+ filename: precheck.filename,
1571
+ fileSizeBytes: precheck.fileSizeBytes,
1572
+ };
1573
+ // Live dispatch is always 'live' — fold one more leg into the
1574
+ // aggregator. Idempotent if metadata + pre-check were also live
1575
+ // ('live' + 'live' = 'live'); promotes 'cache' to 'mixed' when
1576
+ // metadata/pre-check served from cache.
1577
+ sourceAgg.record('live', null);
1578
+ const liveAgg = sourceAgg.result();
1579
+ // resolved_ids slot — pre-check returned the resolved column ID
1580
+ // for the file token; echo it into the envelope's
1581
+ // `meta.resolved_ids` so agents can confirm token-to-ID resolution
1582
+ // (mirrors the M38 single-item envelope at `runItemUpdateSingleFileDispatch`).
1583
+ const resolvedIds = { [inputs.m38.token]: inputs.m38.columnId };
1584
+ // Resolution source for foldAndRemap — defaults to 'live' when
1585
+ // pre-check didn't record one (no resolveColumnWithRefresh leg
1586
+ // fired; the only path that's possible is the no-setEntries
1587
+ // shortcut, which doesn't reach this helper). The remap probe
1588
+ // refreshes board metadata + re-checks the file column's
1589
+ // `archived` flag when a Monday-side `validation_failed`
1590
+ // surfaces against cache-served file-column resolution. Codex
1591
+ // IMPL R1 P1-1 fix.
1592
+ const remapSource = inputs.m38.source ?? 'live';
1593
+ // Discriminator for the dispatch shape: fail-fast (default,
1594
+ // applied to the v0.1 fail-fast bulk path's `applied_to` decoration)
1595
+ // vs `--continue-on-error` (partial-success per-record envelope).
1596
+ // M30 D2 closure pins `--concurrency requires --continue-on-error`,
1597
+ // so fail-fast bulk file dispatch is always sequential N=1.
1598
+ const continueOnError = inputs.parsed.continueOnError === true;
1599
+ if (!continueOnError) {
1600
+ // Fail-fast bulk file dispatch. Sequential loop; first per-item
1601
+ // failure aborts whole-call. Track applied (item_id, asset)
1602
+ // pairs so the failure decoration can echo `applied_to` (matches
1603
+ // the JSON-bulk fail-fast pattern at runBulk's main loop).
1604
+ const appliedAssets = [];
1605
+ for (const itemId of inputs.matchedItemIds) {
1606
+ try {
1607
+ const result = await executeFileColumnSet({
1608
+ client: inputs.client,
1609
+ multipart: inputs.multipart,
1610
+ itemId,
1611
+ entry,
1612
+ signal: inputs.ctx.signal,
1613
+ retries: inputs.retries,
1614
+ });
1615
+ appliedAssets.push({ itemId, asset: result.asset });
1616
+ }
1617
+ catch (err) {
1618
+ if (err instanceof MondayCliError) {
1619
+ // Codex IMPL R1 P2-2 fix: if any prior item applied
1620
+ // successfully, the board's asset state already mutated
1621
+ // wire-side — invalidate the cache BEFORE re-throwing the
1622
+ // fail-fast error so a follow-up read doesn't serve stale
1623
+ // metadata. Mirrors the M38 single-item invalidate-on-
1624
+ // success pattern; the JSON-bulk fail-fast path has the
1625
+ // same gap (unchanged by this commit — separate lift
1626
+ // candidate per the future-lift-candidate note in the
1627
+ // module docstring).
1628
+ if (appliedAssets.length > 0) {
1629
+ await invalidateBoard(inputs.boardId, inputs.ctx.env);
1630
+ }
1631
+ // Codex IMPL R1 P1-1 fix: apply `foldAndRemap` BEFORE
1632
+ // building the decoration so per-item failures inherit
1633
+ // the SAME `validation_failed` → `column_archived`
1634
+ // stale-cache remap the JSON-bulk fail-fast path applies
1635
+ // (cli-design §6.5 stable-code rule). Without this, an
1636
+ // archived file column surfaces `validation_failed` on
1637
+ // file-bulk dispatch but `column_archived` on JSON-bulk
1638
+ // dispatch — agents keying on the stable code see
1639
+ // inconsistent outcomes for the same root cause.
1640
+ const remapped = await foldAndRemap({
1641
+ err,
1642
+ warnings: inputs.m38.warnings,
1643
+ client: inputs.client,
1644
+ boardId: inputs.boardId,
1645
+ columnIds: [inputs.m38.columnId],
1646
+ env: inputs.ctx.env,
1647
+ noCache: inputs.noCache,
1648
+ resolutionSource: remapSource,
1649
+ });
1650
+ // Same decoration shape as the JSON-bulk fail-fast path
1651
+ // (lines ~1334-1361 above). Preserves the existing error
1652
+ // class' fields and grafts `applied_count` / `applied_to`
1653
+ // / `failed_at_item` / `matched_count` onto `details`.
1654
+ const existing = remapped.details ?? {};
1655
+ const decoration = {
1656
+ ...existing,
1657
+ applied_count: appliedAssets.length,
1658
+ applied_to: appliedAssets.map((a) => a.itemId),
1659
+ failed_at_item: itemId,
1660
+ matched_count: inputs.matchedItemIds.length,
1661
+ };
1662
+ if (remapped.code === 'usage_error') {
1663
+ throw new UsageError(remapped.message, {
1664
+ ...(remapped.cause === undefined
1665
+ ? {}
1666
+ : { cause: remapped.cause }),
1667
+ details: decoration,
1668
+ });
1669
+ }
1670
+ throw new ApiError(remapped.code, remapped.message, {
1671
+ ...(remapped.cause === undefined
1672
+ ? {}
1673
+ : { cause: remapped.cause }),
1674
+ ...(remapped.httpStatus === undefined
1675
+ ? {}
1676
+ : { httpStatus: remapped.httpStatus }),
1677
+ ...(remapped.mondayCode === undefined
1678
+ ? {}
1679
+ : { mondayCode: remapped.mondayCode }),
1680
+ ...(remapped.requestId === undefined
1681
+ ? {}
1682
+ : { requestId: remapped.requestId }),
1683
+ retryable: remapped.retryable,
1684
+ ...(remapped.retryAfterSeconds === undefined
1685
+ ? {}
1686
+ : { retryAfterSeconds: remapped.retryAfterSeconds }),
1687
+ details: decoration,
1688
+ });
1689
+ }
1690
+ // Non-CliError programmer bug — re-throw to the runner's
1691
+ // catch-all (surfaces as `internal_error` whole-call;
1692
+ // mirrors the JSON-bulk fail-fast path). The partial-
1693
+ // success invalidate above doesn't fire for this branch
1694
+ // because non-CliError throws indicate broken contract,
1695
+ // not partial-mutation state worth preserving.
1696
+ throw err;
1697
+ }
1698
+ }
1699
+ // Every item applied — single board-cache invalidate before emit
1700
+ // (mirrors M38 single-leg invalidate timing).
1701
+ await invalidateBoard(inputs.boardId, inputs.ctx.env);
1702
+ const results = appliedAssets.map(({ itemId, asset }) => ({
1703
+ item_id: itemId,
1704
+ ok: true,
1705
+ asset,
1706
+ }));
1707
+ const data = {
1708
+ operation: 'item_update_bulk_file_set',
1709
+ summary: {
1710
+ matched_count: inputs.matchedItemIds.length,
1711
+ applied_count: appliedAssets.length,
1712
+ failed_count: 0,
1713
+ board_id: inputs.boardId,
1714
+ column_id: inputs.m38.columnId,
1715
+ filename: precheck.filename,
1716
+ file_size_bytes: precheck.fileSizeBytes,
1717
+ },
1718
+ results,
1719
+ };
1720
+ emitMutation({
1721
+ ctx: inputs.ctx,
1722
+ data,
1723
+ schema: bulkFileSetDataSchema,
1724
+ programOpts: inputs.programOpts,
1725
+ warnings: combinedWarnings,
1726
+ source: liveAgg.source,
1727
+ cacheAgeSeconds: liveAgg.cacheAgeSeconds,
1728
+ apiVersion: inputs.apiVersion,
1729
+ resolvedIds,
1730
+ });
1731
+ return;
1732
+ }
1733
+ // `--continue-on-error` path. Per-item failures land as per-record
1734
+ // `error: {code, message}` slots; the envelope is `ok: true`
1735
+ // regardless of how many items failed (universal partial-success
1736
+ // rule per cli-design §6.4). `internal_error` re-throws whole-call
1737
+ // via the shared dispatcher's escape hatch — schema drift in the
1738
+ // multipart response surfaces as top-level `ok: false`, not
1739
+ // papered over as a per-record slot.
1740
+ const assetById = new Map();
1741
+ const perTargetDispatch = async ({ targetId, }) => {
1742
+ try {
1743
+ const result = await executeFileColumnSet({
1744
+ client: inputs.client,
1745
+ multipart: inputs.multipart,
1746
+ itemId: targetId,
1747
+ entry,
1748
+ signal: inputs.ctx.signal,
1749
+ retries: inputs.retries,
1750
+ });
1751
+ assetById.set(targetId, result.asset);
1752
+ }
1753
+ catch (err) {
1754
+ if (err instanceof MondayCliError) {
1755
+ // Codex IMPL R1 P1-1 fix: apply `foldAndRemap` BEFORE
1756
+ // re-throwing into the shared dispatcher so per-record
1757
+ // `error.code` carries the SAME `column_archived` stable
1758
+ // code the JSON-bulk partial-success path emits. Mirrors
1759
+ // `runPartialSuccessBulkUpdate`'s perTargetDispatch
1760
+ // closure (src/api/partial-success-bulk.ts:431-475).
1761
+ // `foldAndRemap` NEVER converts a non-`internal_error`
1762
+ // into `internal_error`, so the dispatcher's
1763
+ // `internal_error` re-throw escape hatch (M14 round-2 F1)
1764
+ // stays intact: schema drift still surfaces as top-level
1765
+ // `ok: false` rather than papered over as a per-record
1766
+ // slot.
1767
+ const remapped = await foldAndRemap({
1768
+ err,
1769
+ warnings: inputs.m38.warnings,
1770
+ client: inputs.client,
1771
+ boardId: inputs.boardId,
1772
+ columnIds: [inputs.m38.columnId],
1773
+ env: inputs.ctx.env,
1774
+ noCache: inputs.noCache,
1775
+ resolutionSource: remapSource,
1776
+ });
1777
+ throw remapped;
1778
+ }
1779
+ // Non-CliError — programmer bug. Re-throw through
1780
+ // dispatchSequential / dispatchParallel's non-CliError
1781
+ // branch so the runner's catch-all surfaces as
1782
+ // internal_error (whole-call, not per-record).
1783
+ throw err;
1784
+ }
1785
+ };
1786
+ // Routing — `--concurrency > 1` routes through dispatchParallel
1787
+ // (bounded async-pool); absent / `=== 1` routes through
1788
+ // dispatchSequential. Both dispatchers thread the optional
1789
+ // `signal` so SIGINT-aware callers see consistent cooperative-
1790
+ // abort semantics (R-NEW-28 axis 6 — identical between routes).
1791
+ let dispatchResults;
1792
+ if (inputs.parsed.concurrency !== undefined &&
1793
+ inputs.parsed.concurrency > 1) {
1794
+ dispatchResults = await dispatchParallel(inputs.matchedItemIds, 'item_id', perTargetDispatch, inputs.parsed.concurrency, inputs.ctx.signal);
1795
+ }
1796
+ else {
1797
+ dispatchResults = await dispatchSequential(inputs.matchedItemIds, 'item_id', perTargetDispatch, inputs.ctx.signal);
1798
+ }
1799
+ // Single post-dispatch invalidate. Fires even when every per-item
1800
+ // dispatch failed (failed_count === matched_count) — wire calls
1801
+ // still fired against Monday, so the metadata cache's view of the
1802
+ // file column's content count may be stale even if the asset
1803
+ // didn't land. Cheap to always fire; expensive if missed.
1804
+ await invalidateBoard(inputs.boardId, inputs.ctx.env);
1805
+ // Fold dispatcher results + side-map assets into the
1806
+ // BulkFileSetResult[] shape. Mirrors `foldPartialSuccessBulkResult`
1807
+ // (`src/api/partial-success-bulk.ts`) but with the file-dispatch's
1808
+ // `asset` slot replacing the JSON path's `item` projection.
1809
+ const results = dispatchResults.map((row) => {
1810
+ const itemIdSlot = row.item_id;
1811
+ /* c8 ignore next 8 — dispatcher contract: every result row
1812
+ carries the id-field slot (populated by the dispatch helper);
1813
+ this guard catches a contract violation that would surface as
1814
+ a programmer bug, not a Monday-side failure. */
1815
+ if (typeof itemIdSlot !== 'string' || itemIdSlot.length === 0) {
1816
+ throw new ApiError('internal_error', 'bulk file dispatch result row is missing the `item_id` field — dispatcher contract violation.', { details: { record_keys: Object.keys(row) } });
1817
+ }
1818
+ if (row.ok) {
1819
+ const asset = assetById.get(itemIdSlot);
1820
+ /* c8 ignore next 8 — side-map invariant: every successful
1821
+ per-target dispatch records the asset; this guard catches a
1822
+ wrapper-layer miss (programmer bug). */
1823
+ if (asset === undefined) {
1824
+ throw new ApiError('internal_error', `bulk file dispatch result row for item_id ${itemIdSlot} reported ok: true but no Asset was captured — wrapper-layer side-map miss.`, { details: { item_id: itemIdSlot } });
1825
+ }
1826
+ return { item_id: itemIdSlot, ok: true, asset };
1827
+ }
1828
+ /* c8 ignore next 8 — dispatcher contract: every `ok: false` row
1829
+ carries the `error` slot (populated by the shared dispatcher's
1830
+ per-target error decoration). */
1831
+ if (row.error === undefined) {
1832
+ throw new ApiError('internal_error', `bulk file dispatch result row for item_id ${itemIdSlot} reported ok: false but no error payload was captured — dispatcher contract violation.`, { details: { item_id: itemIdSlot } });
1833
+ }
1834
+ return {
1835
+ item_id: itemIdSlot,
1836
+ ok: false,
1837
+ error: { code: row.error.code, message: row.error.message },
1838
+ };
1839
+ });
1840
+ const appliedCount = results.filter((r) => r.ok).length;
1841
+ const failedCount = results.filter((r) => !r.ok).length;
1842
+ /* c8 ignore next 11 — invariant: every matched item produces
1843
+ exactly one result row (success or failure) under both
1844
+ dispatchers; mismatch would indicate a programmer bug in the
1845
+ dispatcher or the fold. Mirrors `buildPartialSuccessBulkSummary`'s
1846
+ defensive check. */
1847
+ if (appliedCount + failedCount !== inputs.matchedItemIds.length) {
1848
+ throw new ApiError('internal_error', `bulk file dispatch summary invariant violated — matched_count (${String(inputs.matchedItemIds.length)}) !== applied_count (${String(appliedCount)}) + failed_count (${String(failedCount)}).`, {
1849
+ details: {
1850
+ matched_count: inputs.matchedItemIds.length,
1851
+ applied_count: appliedCount,
1852
+ failed_count: failedCount,
1853
+ board_id: inputs.boardId,
1854
+ },
1855
+ });
1856
+ }
1857
+ const data = {
1858
+ operation: 'item_update_bulk_file_set',
1859
+ summary: {
1860
+ matched_count: inputs.matchedItemIds.length,
1861
+ applied_count: appliedCount,
1862
+ failed_count: failedCount,
1863
+ board_id: inputs.boardId,
1864
+ column_id: inputs.m38.columnId,
1865
+ filename: precheck.filename,
1866
+ file_size_bytes: precheck.fileSizeBytes,
1867
+ },
1868
+ results,
1869
+ };
1870
+ emitMutation({
1871
+ ctx: inputs.ctx,
1872
+ data,
1873
+ schema: bulkFileSetDataSchema,
1874
+ programOpts: inputs.programOpts,
1875
+ warnings: combinedWarnings,
1876
+ source: liveAgg.source,
1877
+ cacheAgeSeconds: liveAgg.cacheAgeSeconds,
1878
+ apiVersion: inputs.apiVersion,
1879
+ resolvedIds,
1880
+ });
1881
+ };
935
1882
  //# sourceMappingURL=update.js.map