orizu 0.5.39 → 0.5.41

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 (117) hide show
  1. package/README.md +77 -0
  2. package/dist/dataset-clone-workflow.d.ts +11 -0
  3. package/dist/dataset-clone-workflow.d.ts.map +1 -0
  4. package/dist/dataset-clone-workflow.js +100 -0
  5. package/dist/dataset-commands.d.ts +3 -1
  6. package/dist/dataset-commands.d.ts.map +1 -1
  7. package/dist/dataset-commands.js +107 -91
  8. package/dist/dataset-delete-workflow.d.ts +4 -0
  9. package/dist/dataset-delete-workflow.d.ts.map +1 -0
  10. package/dist/dataset-delete-workflow.js +75 -0
  11. package/dist/dataset-lock-workflow.d.ts +12 -0
  12. package/dist/dataset-lock-workflow.d.ts.map +1 -0
  13. package/dist/dataset-lock-workflow.js +76 -0
  14. package/dist/dataset-readme-workflow.d.ts +13 -0
  15. package/dist/dataset-readme-workflow.d.ts.map +1 -0
  16. package/dist/dataset-readme-workflow.js +82 -0
  17. package/dist/dataset-row-workflow.d.ts +14 -0
  18. package/dist/dataset-row-workflow.d.ts.map +1 -0
  19. package/dist/dataset-row-workflow.js +202 -0
  20. package/dist/dataset-split-reads.d.ts +28 -0
  21. package/dist/dataset-split-reads.d.ts.map +1 -0
  22. package/dist/dataset-split-reads.js +47 -0
  23. package/dist/dataset-transfer-upload.d.ts +2 -6
  24. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  25. package/dist/dataset-transfer-upload.js +9 -2
  26. package/dist/dataset-upload-snapshot.d.ts.map +1 -1
  27. package/dist/dataset-upload-snapshot.js +7 -1
  28. package/dist/dataset-version-workflow.d.ts +24 -0
  29. package/dist/dataset-version-workflow.d.ts.map +1 -0
  30. package/dist/dataset-version-workflow.js +99 -0
  31. package/dist/feedback-cli.js +3 -3
  32. package/dist/help.d.ts.map +1 -1
  33. package/dist/help.js +29 -3
  34. package/dist/hosted-boot.d.ts +1 -0
  35. package/dist/hosted-boot.d.ts.map +1 -1
  36. package/dist/hosted-boot.js +17 -0
  37. package/dist/hosted-harness-opencode.d.ts +5 -6
  38. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  39. package/dist/hosted-harness-opencode.js +73 -59
  40. package/dist/hosted-loop-lifecycle.d.ts +6 -11
  41. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  42. package/dist/hosted-loop-lifecycle.js +6 -11
  43. package/dist/hosted-loop.d.ts +9 -0
  44. package/dist/hosted-loop.d.ts.map +1 -1
  45. package/dist/hosted-loop.js +19 -10
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +11 -3
  48. package/dist/instruction-delivery.d.ts +14 -0
  49. package/dist/instruction-delivery.d.ts.map +1 -0
  50. package/dist/instruction-delivery.js +103 -0
  51. package/dist/instruction-set-sync/index.d.ts +17 -0
  52. package/dist/instruction-set-sync/index.d.ts.map +1 -1
  53. package/dist/instruction-set-sync/index.js +44 -17
  54. package/dist/instruction-set-update/index.d.ts +2 -2
  55. package/dist/instruction-set-update/index.d.ts.map +1 -1
  56. package/dist/instruction-set-update/index.js +2 -2
  57. package/dist/instruction-sets-cli.d.ts.map +1 -1
  58. package/dist/instruction-sets-cli.js +19 -27
  59. package/dist/json-response.d.ts +1 -0
  60. package/dist/json-response.d.ts.map +1 -1
  61. package/dist/json-response.js +7 -0
  62. package/dist/last-error-record.d.ts +4 -2
  63. package/dist/last-error-record.d.ts.map +1 -1
  64. package/dist/last-error-record.js +10 -68
  65. package/dist/last-error-workflow.d.ts +4 -0
  66. package/dist/last-error-workflow.d.ts.map +1 -0
  67. package/dist/last-error-workflow.js +78 -0
  68. package/dist/preview-runtime.d.ts +2 -2
  69. package/dist/preview-runtime.d.ts.map +1 -1
  70. package/dist/preview-runtime.js +37 -7
  71. package/dist/task-commands.d.ts.map +1 -1
  72. package/dist/task-commands.js +9 -16
  73. package/dist/task-export-workflow.d.ts +4 -0
  74. package/dist/task-export-workflow.d.ts.map +1 -0
  75. package/dist/task-export-workflow.js +46 -0
  76. package/dist/task-report-workflow.d.ts +47 -0
  77. package/dist/task-report-workflow.d.ts.map +1 -0
  78. package/dist/task-report-workflow.js +160 -0
  79. package/dist/workbench-cli.js +1 -1
  80. package/package.json +1 -1
  81. package/src/dataset-clone-workflow.ts +100 -0
  82. package/src/dataset-commands.ts +95 -142
  83. package/src/dataset-delete-workflow.ts +72 -0
  84. package/src/dataset-lock-workflow.ts +74 -0
  85. package/src/dataset-readme-workflow.ts +80 -0
  86. package/src/dataset-row-workflow.ts +248 -0
  87. package/src/dataset-split-reads.ts +43 -0
  88. package/src/dataset-transfer-upload.ts +12 -5
  89. package/src/dataset-upload-snapshot.ts +8 -1
  90. package/src/dataset-version-workflow.ts +105 -0
  91. package/src/feedback-cli.ts +4 -4
  92. package/src/help.ts +29 -3
  93. package/src/hosted-boot.ts +16 -1
  94. package/src/hosted-harness-opencode.ts +74 -72
  95. package/src/hosted-loop-lifecycle.ts +6 -11
  96. package/src/hosted-loop.ts +20 -10
  97. package/src/hosted-model-cache.d.mts +2 -0
  98. package/src/hosted-model-cache.mjs +53 -0
  99. package/src/index.ts +12 -2
  100. package/src/instruction-delivery.ts +123 -0
  101. package/src/instruction-set-sync/index.ts +61 -14
  102. package/src/instruction-set-update/index.ts +4 -3
  103. package/src/instruction-sets-cli.ts +19 -27
  104. package/src/json-response.ts +8 -0
  105. package/src/last-error-record.ts +11 -85
  106. package/src/last-error-workflow.ts +121 -0
  107. package/src/preview-runtime.ts +40 -10
  108. package/src/task-commands.ts +9 -43
  109. package/src/task-export-workflow.ts +56 -0
  110. package/src/task-report-workflow.ts +179 -0
  111. package/src/workbench-cli.ts +1 -1
  112. package/vendor/skills/orizu/references/cli-reference.md +34 -0
  113. package/vendor/skills/orizu/references/dataset-design.md +1 -1
  114. package/dist/dataset-edit-rows.d.ts +0 -10
  115. package/dist/dataset-edit-rows.d.ts.map +0 -1
  116. package/dist/dataset-edit-rows.js +0 -90
  117. package/src/dataset-edit-rows.ts +0 -124
@@ -509,17 +509,25 @@ function removeObservedSyncLock(lockPath: string, observed: ObservedSyncLock): b
509
509
  return true
510
510
  }
511
511
 
512
- export function withSyncLock<T>(appRoot: string, operation: () => T): T {
512
+ export interface SyncLockClaim { lockPath: string; metadata: string; deadline: number }
513
+
514
+ export function startSyncLockClaim(appRoot: string): SyncLockClaim {
513
515
  mkdirSync(appRoot, { recursive: true })
514
- const lockPath = join(appRoot, '.orizu.lock.json.lock')
515
- const metadata = `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`
516
516
  const lockWaitMs = processTestMilliseconds(
517
517
  'ORIZU_TEST_SYNC_LOCK_WAIT_MS',
518
518
  100,
519
519
  SYNC_LOCK_WAIT_MS
520
520
  ) ?? SYNC_LOCK_WAIT_MS
521
- const deadline = Date.now() + lockWaitMs
521
+ return {
522
+ lockPath: join(appRoot, '.orizu.lock.json.lock'),
523
+ metadata: `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
524
+ deadline: Date.now() + lockWaitMs,
525
+ }
526
+ }
522
527
 
528
+ // One claim attempt: returns the release function, or how long to wait before
529
+ // trying again. Waiting is the caller's job so it can be blocking or not.
530
+ export function tryClaimSyncLock({ lockPath, metadata, deadline }: SyncLockClaim): (() => void) | number {
523
531
  while (true) {
524
532
  try {
525
533
  writeFileSync(lockPath, metadata, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
@@ -540,17 +548,26 @@ export function withSyncLock<T>(appRoot: string, operation: () => T): T {
540
548
  if (Date.now() >= deadline) {
541
549
  throw new Error(`instruction_set_sync_lock_busy${busyPid === undefined ? '' : `:${busyPid}`}`)
542
550
  }
543
- sleepSync(Math.min(SYNC_LOCK_RETRY_MS, Math.max(1, deadline - Date.now())))
551
+ return Math.min(SYNC_LOCK_RETRY_MS, Math.max(1, deadline - Date.now()))
544
552
  }
545
553
  }
546
554
 
547
- const release = () => {
555
+ return () => {
548
556
  try {
549
557
  if (readFileSync(lockPath, 'utf8') === metadata) rmSync(lockPath)
550
558
  } catch (error) {
551
559
  if (!(isNodeError(error) && error.code === 'ENOENT')) throw error
552
560
  }
553
561
  }
562
+ }
563
+
564
+ export function withSyncLock<T>(appRoot: string, operation: () => T): T {
565
+ const claim = startSyncLockClaim(appRoot)
566
+ let release = tryClaimSyncLock(claim)
567
+ while (typeof release === 'number') {
568
+ sleepSync(release)
569
+ release = tryClaimSyncLock(claim)
570
+ }
554
571
  let result: T
555
572
  try {
556
573
  result = operation()
@@ -600,6 +617,16 @@ export interface SyncDiskOptions {
600
617
  platform?: NodeJS.Platform
601
618
  }
602
619
 
620
+ export interface StagedSync {
621
+ appRoot: string
622
+ destination: string
623
+ wasPresent: boolean
624
+ /** Publishes the staged version; the caller must hold the folder lock. Returns warnings. */
625
+ publish: () => string[]
626
+ /** Removes the staged version if it was not published. Safe to call more than once. */
627
+ discard: () => void
628
+ }
629
+
603
630
  export function syncPayloadToDisk(
604
631
  out: string,
605
632
  project: string,
@@ -607,6 +634,22 @@ export function syncPayloadToDisk(
607
634
  payload: SyncPayload,
608
635
  options: SyncDiskOptions = {}
609
636
  ): SyncDiskResult {
637
+ const staged = stageSyncPayload(out, project, plan, payload, options)
638
+ try {
639
+ const warnings = withSyncLock(staged.appRoot, staged.publish)
640
+ return { destination: staged.destination, wasPresent: staged.wasPresent, warnings }
641
+ } finally {
642
+ staged.discard()
643
+ }
644
+ }
645
+
646
+ export function stageSyncPayload(
647
+ out: string,
648
+ project: string,
649
+ plan: SyncRequestPlan,
650
+ payload: SyncPayload,
651
+ options: SyncDiskOptions = {}
652
+ ): StagedSync {
610
653
  const target = parseSyncTarget(options.target)
611
654
  const now = options.now ?? (() => new Date())
612
655
  const set = payload.instructionSet
@@ -717,9 +760,11 @@ export function syncPayloadToDisk(
717
760
  assertExistingVersionMatches(destination, componentHashes, manifestBytes, generatedBytes)
718
761
  }
719
762
 
720
- let warnings: string[] = []
721
- try {
722
- withSyncLock(appRoot, () => {
763
+ return {
764
+ appRoot,
765
+ destination,
766
+ wasPresent,
767
+ publish: () => {
723
768
  holdForProcessTest('ORIZU_TEST_SYNC_HOLD_BEFORE_PUBLISH_MS')
724
769
  const currentLock = readLock(appRoot, project)
725
770
  holdForProcessTest('ORIZU_TEST_SYNC_HOLD_LOCK_MS')
@@ -786,8 +831,9 @@ export function syncPayloadToDisk(
786
831
  }
787
832
  stagedTemp = null
788
833
  }
789
- warnings = emitManagedArtifacts(out, appRoot, currentLock, options.forceHelpers ?? false, target, journal)
834
+ const warnings = emitManagedArtifacts(out, appRoot, currentLock, options.forceHelpers ?? false, target, journal)
790
835
  journal.write(join(appRoot, 'orizu.lock.json'), serializeLock(currentLock))
836
+ return warnings
791
837
  } catch (error) {
792
838
  journal.rollback()
793
839
  currentLock.helpers = priorHelpers
@@ -802,9 +848,10 @@ export function syncPayloadToDisk(
802
848
  }
803
849
  throw error
804
850
  }
805
- })
806
- } finally {
807
- if (stagedTemp) rmSync(stagedTemp, { recursive: true, force: true })
851
+ },
852
+ discard: () => {
853
+ if (stagedTemp) rmSync(stagedTemp, { recursive: true, force: true })
854
+ stagedTemp = null
855
+ },
808
856
  }
809
- return { destination, wasPresent, warnings }
810
857
  }
@@ -11,7 +11,7 @@ import {
11
11
  type InstructionSetLockV1,
12
12
  } from '../instruction-set-lock/index.js'
13
13
  import { assertOutputConfined, reconcileGeneratedIndex } from '../instruction-set-sync/helpers.js'
14
- import { profileSlug, recordResolvedPayloadInLock, syncPayloadToDisk, withSyncLock, type SyncPayload } from '../instruction-set-sync/index.js'
14
+ import { profileSlug, recordResolvedPayloadInLock, syncPayloadToDisk, withSyncLock, type SyncDiskResult, type SyncPayload } from '../instruction-set-sync/index.js'
15
15
  import { verifyInstructionSetTree, verifyMaterializedVersionAgainstLock } from '../instruction-set-verify/index.js'
16
16
 
17
17
  export interface UpdateResolution {
@@ -222,7 +222,8 @@ export async function applyUpdate(
222
222
  project: string,
223
223
  plan: UpdatePlan,
224
224
  noSync: boolean,
225
- now: () => Date = () => new Date()
225
+ now: () => Date = () => new Date(),
226
+ sync: (...args: Parameters<typeof syncPayloadToDisk>) => SyncDiskResult | Promise<SyncDiskResult> = syncPayloadToDisk
226
227
  ): Promise<UpdateResult> {
227
228
  const absent: string[] = []
228
229
  const warnings: string[] = []
@@ -271,7 +272,7 @@ export async function applyUpdate(
271
272
  let lock = plan.lock
272
273
  for (const resolution of plan.resolutions) {
273
274
  const set = resolution.payload.instructionSet
274
- const result = syncPayloadToDisk(out, project, {
275
+ const result = await sync(out, project, {
275
276
  parsed: { set: set.slug, profile: resolution.profileIdentity },
276
277
  path: '',
277
278
  lock,
@@ -1,9 +1,9 @@
1
1
  import { authedFetch } from './http.js'
2
2
  import { INSTRUCTION_SET_COMPONENT_KEY } from './instruction-set-lock/index.js'
3
3
  import { loadInstructionSetManifest, MAX_INSTRUCTION_SET_COMPONENT_BYTES, type InstructionSetManifest } from './instruction-set-manifest.js'
4
- import { sanitizeHumanInlineText, sanitizeTerminalText } from './json-response.js'
5
- import { parseSyncTarget, planSyncRequest, syncPayloadToDisk, type SyncPayload, type SyncTarget } from './instruction-set-sync/index.js'
6
- import { applyPrune, applyUpdate, lockedProfileIdentity, makeUpdatePlan, planPrune, PruneKeepUnresolvedError, readUpdateLock } from './instruction-set-update/index.js'
4
+ import { responsePayload, sanitizeHumanInlineText, sanitizeTerminalText } from './json-response.js'
5
+ import { parseSyncTarget, planSyncRequest, type SyncTarget } from './instruction-set-sync/index.js'
6
+ import { applyPrune, planPrune, PruneKeepUnresolvedError } from './instruction-set-update/index.js'
7
7
  import { printVerifyReport, verifyInstructionSetTree } from './instruction-set-verify/index.js'
8
8
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
9
9
  import { join } from 'node:path'
@@ -74,12 +74,8 @@ function lifecycleOptionValues(args: string[], command: 'update' | 'prune', flag
74
74
  return values
75
75
  }
76
76
 
77
- async function responsePayload(response: Response, action: string): Promise<Record<string, unknown>> {
78
- const payload = await response.json().catch(() => ({})) as Record<string, unknown>
79
- if (!response.ok) {
80
- throw new Error(`${action} failed (${response.status}): ${typeof payload.error === 'string' ? payload.error : response.statusText}`)
81
- }
82
- return payload
77
+ function printInterrupted(io: InstructionSetsCommandIo, message: string) {
78
+ io.print(io.json ? JSON.stringify({ error: message }) : message)
83
79
  }
84
80
 
85
81
  const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/u
@@ -340,23 +336,14 @@ export async function instructionSetsCommand(args: string[], io: InstructionSets
340
336
  const project = await io.resolveProjectSlug(projectOption)
341
337
  if (subcommand === 'update') {
342
338
  const output = lifecycleOptionValues(args, 'update', '--out')[0] ?? '.'
343
- const lock = readUpdateLock(output, project)
344
- const payloads: SyncPayload[] = []
345
- for (const [setSlug, set] of Object.entries(lock.instructionSets)) {
346
- const barePath = `/api/cli/instruction-sets/${encodeURIComponent(setSlug)}/sync?project=${encodeURIComponent(project)}`
347
- payloads.push(await responsePayload(await authedFetch(barePath, { method: 'GET' }), 'Instruction sets update') as unknown as SyncPayload)
348
- for (const profileSlugValue of Object.keys(set.profiles)) {
349
- const identity = lockedProfileIdentity(output, setSlug, profileSlugValue)
350
- const query = new URLSearchParams({ project, profile: identity })
351
- const path = `/api/cli/instruction-sets/${encodeURIComponent(setSlug)}/sync?${query.toString()}`
352
- payloads.push(await responsePayload(await authedFetch(path, { method: 'GET' }), 'Instruction sets update') as unknown as SyncPayload)
353
- }
354
- }
355
- const plan = makeUpdatePlan(project, lock, payloads)
356
339
  const isApproved = args.includes('--yes')
357
- const result = isApproved
358
- ? await applyUpdate(output, project, plan, args.includes('--no-sync'))
359
- : { absent: [], warnings: [] }
340
+ const { updateInstructionSets } = await import('./instruction-delivery.js')
341
+ const updated = await updateInstructionSets(output, project, isApproved, args.includes('--no-sync'))
342
+ if ('interrupted' in updated) {
343
+ printInterrupted(io, 'Instruction sets update interrupted; some sets may already be updated. Run orizu instructions update again, then orizu instructions verify.')
344
+ return updated.interrupted
345
+ }
346
+ const { plan, result } = updated
360
347
  if (io.json) io.print(JSON.stringify({ plan: plan.lines, applied: isApproved, absent: result.absent, warnings: result.warnings }))
361
348
  else {
362
349
  for (const line of plan.lines) io.print(line)
@@ -527,11 +514,16 @@ export async function instructionSetsCommand(args: string[], io: InstructionSets
527
514
  if (unknownOption) throw new Error(`instruction_set_sync_option_unknown:${unknownOption}`)
528
515
  const output = requiredOptionValue(args, subcommand, '--out') ?? '.'
529
516
  const plan = planSyncRequest(reference, requiredOptionValue(args, subcommand, '--version'), output, project)
530
- const payload = await responsePayload(await authedFetch(plan.path, { method: 'GET' }), 'Instruction sets sync') as unknown as SyncPayload
531
- const result = syncPayloadToDisk(output, project, plan, payload, {
517
+ const { syncInstructionSet } = await import('./instruction-delivery.js')
518
+ const delivered = await syncInstructionSet(output, project, plan, {
532
519
  forceHelpers: args.includes('--force-helpers'),
533
520
  target: syncTarget!,
534
521
  })
522
+ if ('interrupted' in delivered) {
523
+ printInterrupted(io, 'Instruction set sync interrupted; any unpublished staged version was removed. Run orizu instructions sync again.')
524
+ return delivered.interrupted
525
+ }
526
+ const { payload, result } = delivered
535
527
  if (io.json) io.print(JSON.stringify({ ...payload, warnings: result.warnings }))
536
528
  else {
537
529
  io.print(`Synced ${reference} to ${result.destination}`)
@@ -35,3 +35,11 @@ export async function parseJsonResponse<T>(
35
35
  )
36
36
  }
37
37
  }
38
+
39
+ export async function responsePayload(response: Response, action: string): Promise<Record<string, unknown>> {
40
+ const payload = await response.json().catch(() => ({})) as Record<string, unknown>
41
+ if (!response.ok) {
42
+ throw new Error(`${action} failed (${response.status}): ${typeof payload.error === 'string' ? payload.error : response.statusText}`)
43
+ }
44
+ return payload
45
+ }
@@ -1,16 +1,3 @@
1
- import { randomBytes } from 'crypto'
2
- import {
3
- chmodSync,
4
- closeSync,
5
- fsyncSync,
6
- mkdirSync,
7
- openSync,
8
- readFileSync,
9
- renameSync,
10
- rmSync,
11
- statSync,
12
- writeFileSync,
13
- } from 'fs'
14
1
  import { homedir } from 'os'
15
2
  import { join } from 'path'
16
3
 
@@ -235,7 +222,7 @@ function resolveHomeDir(): string | null {
235
222
  }
236
223
  }
237
224
 
238
- function isStringWithinCap(
225
+ export function isStringWithinCap(
239
226
  value: unknown,
240
227
  maximumCharacters: number,
241
228
  wasTruncated: boolean
@@ -247,45 +234,12 @@ function isStringWithinCap(
247
234
  && value.endsWith(LAST_ERROR_TRUNCATION_MARKER)
248
235
  }
249
236
 
250
- function boundedServerBaseUrl(value: string | null | undefined): string | null {
237
+ export function boundedServerBaseUrl(value: string | null | undefined): string | null {
251
238
  return value && Buffer.byteLength(value, 'utf8') <= LAST_ERROR_SERVER_BASE_URL_MAX_BYTES
252
239
  ? value
253
240
  : null
254
241
  }
255
242
 
256
- function isLastErrorRecord(value: unknown): value is LastErrorRecord {
257
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false
258
- const record = value as Partial<LastErrorRecord>
259
- if (record.version !== LAST_ERROR_RECORD_VERSION) return false
260
- if (typeof record.recordedAt !== 'string' || !Number.isFinite(Date.parse(record.recordedAt))) return false
261
- if (typeof record.cliVersion !== 'string' && record.cliVersion !== null) return false
262
- if (record.code !== undefined && record.code !== null && (
263
- typeof record.code !== 'string'
264
- || Array.from(record.code).length > LAST_ERROR_CODE_MAX_CHARS
265
- )) return false
266
- if (record.serverBaseUrl !== undefined && typeof record.serverBaseUrl !== 'string' && record.serverBaseUrl !== null) return false
267
- if (record.teamSlug !== undefined && typeof record.teamSlug !== 'string' && record.teamSlug !== null) return false
268
- if (typeof record.argvTruncated !== 'boolean') return false
269
- const argvTruncated = record.argvTruncated
270
- if (record.command !== null && !isStringWithinCap(
271
- record.command,
272
- LAST_ERROR_ARG_MAX_CHARS,
273
- argvTruncated
274
- )) return false
275
- if (!Array.isArray(record.argv) || record.argv.length > LAST_ERROR_MAX_ARGS) return false
276
- if (!record.argv.every(argument => isStringWithinCap(
277
- argument,
278
- LAST_ERROR_ARG_MAX_CHARS,
279
- argvTruncated
280
- ))) return false
281
- if (typeof record.messageTruncated !== 'boolean') return false
282
- return isStringWithinCap(
283
- record.message,
284
- LAST_ERROR_MESSAGE_MAX_CHARS,
285
- record.messageTruncated
286
- )
287
- }
288
-
289
243
  export function buildLastErrorRecord(input: LastErrorRecordInput): LastErrorRecord {
290
244
  const context: ScrubContext = {
291
245
  homeDir: input.homeDir ?? null,
@@ -350,15 +304,12 @@ export function getLastErrorRecordPath(configDir?: string): string {
350
304
  return join(configDir ?? getConfigDir(), LAST_ERROR_FILENAME)
351
305
  }
352
306
 
353
- export function writeLastErrorRecord(
307
+ export async function writeLastErrorRecord(
354
308
  input: LastErrorRecordInput,
355
309
  options: WriteLastErrorRecordOptions = {}
356
- ): void {
310
+ ): Promise<void> {
357
311
  try {
358
312
  const configDirectory = options.configDir ?? getConfigDir()
359
- mkdirSync(configDirectory, { recursive: true, mode: 0o700 })
360
- chmodSync(configDirectory, 0o700)
361
-
362
313
  const record = buildLastErrorRecord({
363
314
  ...input,
364
315
  homeDir: input.homeDir === undefined ? resolveHomeDir() : input.homeDir,
@@ -371,44 +322,19 @@ export function writeLastErrorRecord(
371
322
  const serialized = serializeRecord(record)
372
323
  if (Buffer.byteLength(serialized, 'utf8') > LAST_ERROR_FILE_MAX_BYTES) return
373
324
 
374
- const path = getLastErrorRecordPath(configDirectory)
375
- const temporaryPath = join(
376
- configDirectory,
377
- `.${LAST_ERROR_FILENAME}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
378
- )
379
-
380
- try {
381
- const fileDescriptor = openSync(temporaryPath, 'wx', 0o600)
382
- try {
383
- writeFileSync(fileDescriptor, serialized, 'utf8')
384
- fsyncSync(fileDescriptor)
385
- } finally {
386
- closeSync(fileDescriptor)
387
- }
388
- chmodSync(temporaryPath, 0o600)
389
- renameSync(temporaryPath, path)
390
- chmodSync(path, 0o600)
391
- } finally {
392
- rmSync(temporaryPath, { force: true })
393
- }
325
+ const { publishLastErrorRecord } = await import('./last-error-workflow.js')
326
+ await publishLastErrorRecord(configDirectory, serialized)
394
327
  } catch {
395
- // A diagnostic record is best effort and must never replace the original CLI failure.
328
+ // A diagnostic record is best effort and must never replace the original CLI failure,
329
+ // including when Effect cannot load (a packed CLI without its dependencies).
396
330
  }
397
331
  }
398
332
 
399
- export function readLastErrorRecord(configDir?: string): LastErrorRecord | null {
333
+ export async function readLastErrorRecord(configDir?: string): Promise<LastErrorRecord | null> {
400
334
  try {
401
335
  const path = getLastErrorRecordPath(configDir)
402
- if (statSync(path).size > LAST_ERROR_FILE_MAX_BYTES) return null
403
- const record = JSON.parse(readFileSync(path, 'utf8')) as unknown
404
- return isLastErrorRecord(record)
405
- ? {
406
- ...record,
407
- code: record.code ?? null,
408
- serverBaseUrl: boundedServerBaseUrl(record.serverBaseUrl),
409
- teamSlug: record.teamSlug ?? null,
410
- }
411
- : null
336
+ const { loadLastErrorRecord } = await import('./last-error-workflow.js')
337
+ return await loadLastErrorRecord(path)
412
338
  } catch {
413
339
  return null
414
340
  }
@@ -0,0 +1,121 @@
1
+ import { randomBytes } from 'crypto'
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from 'fs'
14
+ import { join } from 'path'
15
+ import { Effect, Schema } from 'effect'
16
+
17
+ import {
18
+ LAST_ERROR_ARG_MAX_CHARS,
19
+ LAST_ERROR_CODE_MAX_CHARS,
20
+ LAST_ERROR_FILE_MAX_BYTES,
21
+ LAST_ERROR_MAX_ARGS,
22
+ LAST_ERROR_MESSAGE_MAX_CHARS,
23
+ } from './last-error-caps.js'
24
+ import {
25
+ boundedServerBaseUrl,
26
+ isStringWithinCap,
27
+ LAST_ERROR_FILENAME,
28
+ LAST_ERROR_RECORD_VERSION,
29
+ type LastErrorRecord,
30
+ } from './last-error-record.js'
31
+
32
+ // ORI-2450: Effect owns the last-error file's publication and reading.
33
+ // last-error-record.ts loads this module with a literal dynamic import, so the
34
+ // npm CLI only loads Effect on a failure or a feedback read, and the source
35
+ // bundle still includes it.
36
+
37
+ class LastErrorFileFailure extends Error {
38
+ constructor(readonly operation: string) {
39
+ super(`Could not ${operation}`)
40
+ }
41
+ }
42
+
43
+ const file = <A>(operation: string, action: () => A) => Effect.try({
44
+ try: action,
45
+ catch: () => new LastErrorFileFailure(operation),
46
+ })
47
+
48
+ const NullableString = Schema.NullOr(Schema.String)
49
+
50
+ const LastErrorRecordSchema = Schema.Struct({
51
+ version: Schema.Literal(LAST_ERROR_RECORD_VERSION),
52
+ recordedAt: Schema.String.check(Schema.makeFilter(value => Number.isFinite(Date.parse(value)))),
53
+ cliVersion: NullableString,
54
+ code: Schema.optionalKey(NullableString.check(Schema.makeFilter(value =>
55
+ value === null || Array.from(value).length <= LAST_ERROR_CODE_MAX_CHARS))),
56
+ serverBaseUrl: Schema.optionalKey(NullableString),
57
+ teamSlug: Schema.optionalKey(NullableString),
58
+ command: NullableString,
59
+ argv: Schema.Array(Schema.String),
60
+ argvTruncated: Schema.Boolean,
61
+ message: Schema.String,
62
+ messageTruncated: Schema.Boolean,
63
+ }).check(Schema.makeFilter(record =>
64
+ (record.command === null || isStringWithinCap(record.command, LAST_ERROR_ARG_MAX_CHARS, record.argvTruncated))
65
+ && record.argv.length <= LAST_ERROR_MAX_ARGS
66
+ && record.argv.every(argument => isStringWithinCap(argument, LAST_ERROR_ARG_MAX_CHARS, record.argvTruncated))
67
+ && isStringWithinCap(record.message, LAST_ERROR_MESSAGE_MAX_CHARS, record.messageTruncated)))
68
+
69
+ const publication = (configDirectory: string, serialized: string) => Effect.gen(function*() {
70
+ yield* file('create directory', () => mkdirSync(configDirectory, { recursive: true, mode: 0o700 }))
71
+ yield* file('protect directory', () => chmodSync(configDirectory, 0o700))
72
+ const path = join(configDirectory, LAST_ERROR_FILENAME)
73
+ // Write a private temporary file, then rename it over the record, so a
74
+ // reader never sees a half-written record and a failed write keeps the old one.
75
+ yield* Effect.acquireUseRelease(
76
+ Effect.sync(() => join(
77
+ configDirectory,
78
+ `.${LAST_ERROR_FILENAME}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
79
+ )),
80
+ temporaryPath => Effect.gen(function*() {
81
+ yield* Effect.acquireUseRelease(
82
+ file('open temporary record', () => openSync(temporaryPath, 'wx', 0o600)),
83
+ descriptor => Effect.gen(function*() {
84
+ yield* file('write temporary record', () => writeFileSync(descriptor, serialized, 'utf8'))
85
+ yield* file('flush temporary record', () => fsyncSync(descriptor))
86
+ }),
87
+ descriptor => Effect.sync(() => closeSync(descriptor)),
88
+ )
89
+ yield* file('protect temporary record', () => chmodSync(temporaryPath, 0o600))
90
+ yield* file('publish record', () => renameSync(temporaryPath, path))
91
+ yield* file('protect published record', () => chmodSync(path, 0o600))
92
+ }),
93
+ temporaryPath => Effect.sync(() => rmSync(temporaryPath, { force: true })),
94
+ )
95
+ })
96
+
97
+ export function publishLastErrorRecord(configDirectory: string, serialized: string): Promise<void> {
98
+ return Effect.runPromise(publication(configDirectory, serialized))
99
+ }
100
+
101
+ const loading = (path: string) => Effect.gen(function*() {
102
+ const stat = yield* file('inspect record', () => statSync(path))
103
+ if (stat.size > LAST_ERROR_FILE_MAX_BYTES) return null
104
+ const text = yield* file('read record', () => readFileSync(path, 'utf8'))
105
+ const raw = yield* file('parse record', () => JSON.parse(text) as unknown)
106
+ const record = yield* Schema.decodeUnknownEffect(LastErrorRecordSchema)(raw)
107
+ // The Schema checks the known fields; the reader has always kept any others.
108
+ const admitted: LastErrorRecord = {
109
+ ...(raw as Record<string, unknown>),
110
+ ...record,
111
+ argv: [...record.argv],
112
+ code: record.code ?? null,
113
+ serverBaseUrl: boundedServerBaseUrl(record.serverBaseUrl),
114
+ teamSlug: record.teamSlug ?? null,
115
+ }
116
+ return admitted
117
+ })
118
+
119
+ export function loadLastErrorRecord(path: string): Promise<LastErrorRecord | null> {
120
+ return Effect.runPromise(loading(path).pipe(Effect.orElseSucceed(() => null)))
121
+ }
@@ -112,8 +112,8 @@ interface PreviewPage {
112
112
  screenshot: (options: { path: string; fullPage: boolean }) => Promise<unknown>
113
113
  waitForTimeout: (timeout: number) => Promise<unknown>
114
114
  once?: (event: string, listener: () => void) => unknown
115
- on?: (event: string, listener: () => void) => unknown
116
- off?: (event: string, listener: () => void) => unknown
115
+ on?: (event: string, listener: (error: Error) => void) => unknown
116
+ off?: (event: string, listener: (error: Error) => void) => unknown
117
117
  }
118
118
 
119
119
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
@@ -256,19 +256,32 @@ export async function runLocalAppPreview(options: AppPreviewOptions): Promise<Ap
256
256
  headless: !options.headed,
257
257
  ...(chromiumExecutablePath ? { executablePath: chromiumExecutablePath } : {}),
258
258
  })
259
- const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
260
-
259
+ let page: PreviewPage | null = null
260
+ let handleRenderError: ((error: Error) => void) | undefined
261
+ let hasFinishedKeepingOpen = false
261
262
  try {
262
- await page.goto(url, { waitUntil: 'networkidle' })
263
- await page.waitForSelector('[data-orizu-preview-ready="true"]', { timeout: 15_000 })
263
+ page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
264
+ const renderError = new Promise<never>((_, reject) => {
265
+ handleRenderError = error => reject(new Error(`App preview render failed: ${error.message}`))
266
+ page?.on?.('pageerror', handleRenderError)
267
+ })
268
+ // Listen before navigation: React can fail before the ready marker mounts.
269
+ await Promise.race([
270
+ page.goto(url, { waitUntil: 'networkidle' }).then(() =>
271
+ page!.waitForSelector('[data-orizu-preview-ready="true"]', { timeout: 15_000 })
272
+ ),
273
+ renderError,
274
+ ])
264
275
  if (options.screenshotPath) {
265
276
  await page.screenshot({ path: options.screenshotPath, fullPage: true })
266
277
  }
267
278
  if (options.keepOpen) {
268
279
  await waitForPreviewClose(page, browser)
280
+ hasFinishedKeepingOpen = true
269
281
  }
270
282
  } finally {
271
- if (!options.keepOpen) {
283
+ if (handleRenderError) page?.off?.('pageerror', handleRenderError)
284
+ if (!hasFinishedKeepingOpen) {
272
285
  await browser.close()
273
286
  }
274
287
  }
@@ -826,7 +839,8 @@ export default TextContent
826
839
  import React from 'react'
827
840
  function cn(...values) { return values.filter(Boolean).join(' ') }
828
841
  export function CodeBlock({ code, children, className, ...props }) {
829
- return <pre {...props} className={cn('overflow-auto rounded-md border bg-muted p-3 font-mono text-xs', className)}><code>{children ?? code}</code></pre>
842
+ const codeText = Array.isArray(code) ? code.map(line => line.content).join('\\n') : code
843
+ return <pre {...props} className={cn('overflow-auto rounded-md border bg-muted p-3 font-mono text-xs', className)}><code>{children ?? codeText}</code></pre>
830
844
  }
831
845
  export default CodeBlock
832
846
  `
@@ -843,8 +857,24 @@ export default Prose
843
857
  const contentRendererSource = `
844
858
  import React from 'react'
845
859
  function cn(...values) { return values.filter(Boolean).join(' ') }
846
- export function ContentRenderer({ content, children, className, ...props }) {
847
- return <div {...props} className={cn('whitespace-pre-wrap text-sm leading-relaxed', className)}>{children ?? content}</div>
860
+ export function ContentRenderer({ contentType, content, maxHeight = '400px', customRenderer, className }) {
861
+ let renderedContent
862
+ switch (contentType) {
863
+ case 'image':
864
+ renderedContent = <div className="flex items-center justify-center p-2"><img src={content} alt="Content" width={800} height={600} className="max-w-full object-contain" style={{ maxHeight, height: 'auto', width: 'auto' }} /></div>
865
+ break
866
+ case 'video':
867
+ renderedContent = <div className="flex h-full justify-center p-2"><video src={content} controls className="max-h-full max-w-full" /></div>
868
+ break
869
+ case 'custom':
870
+ renderedContent = customRenderer ? customRenderer({ content }) : <p className="text-destructive">Custom renderer required for custom content type</p>
871
+ break
872
+ default:
873
+ renderedContent = <p>Invalid content type</p>
874
+ }
875
+ return maxHeight
876
+ ? <div className={cn('overflow-auto', className)} style={{ maxHeight }}>{renderedContent}</div>
877
+ : renderedContent
848
878
  }
849
879
  export default ContentRenderer
850
880
  `