orizu 0.5.39 → 0.5.40

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 (85) hide show
  1. package/README.md +77 -0
  2. package/dist/dataset-commands.d.ts +3 -1
  3. package/dist/dataset-commands.d.ts.map +1 -1
  4. package/dist/dataset-commands.js +101 -62
  5. package/dist/dataset-delete-workflow.d.ts +4 -0
  6. package/dist/dataset-delete-workflow.d.ts.map +1 -0
  7. package/dist/dataset-delete-workflow.js +75 -0
  8. package/dist/dataset-lock-workflow.d.ts +12 -0
  9. package/dist/dataset-lock-workflow.d.ts.map +1 -0
  10. package/dist/dataset-lock-workflow.js +76 -0
  11. package/dist/dataset-readme-workflow.d.ts +13 -0
  12. package/dist/dataset-readme-workflow.d.ts.map +1 -0
  13. package/dist/dataset-readme-workflow.js +82 -0
  14. package/dist/dataset-row-workflow.d.ts +14 -0
  15. package/dist/dataset-row-workflow.d.ts.map +1 -0
  16. package/dist/dataset-row-workflow.js +202 -0
  17. package/dist/dataset-split-reads.d.ts +28 -0
  18. package/dist/dataset-split-reads.d.ts.map +1 -0
  19. package/dist/dataset-split-reads.js +47 -0
  20. package/dist/help.d.ts.map +1 -1
  21. package/dist/help.js +29 -3
  22. package/dist/hosted-boot.d.ts +1 -0
  23. package/dist/hosted-boot.d.ts.map +1 -1
  24. package/dist/hosted-boot.js +17 -0
  25. package/dist/hosted-harness-opencode.d.ts +4 -5
  26. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  27. package/dist/hosted-harness-opencode.js +66 -52
  28. package/dist/hosted-loop-lifecycle.d.ts +4 -10
  29. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  30. package/dist/hosted-loop-lifecycle.js +4 -10
  31. package/dist/hosted-loop.d.ts +2 -0
  32. package/dist/hosted-loop.d.ts.map +1 -1
  33. package/dist/hosted-loop.js +5 -4
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +9 -1
  36. package/dist/instruction-delivery.d.ts +14 -0
  37. package/dist/instruction-delivery.d.ts.map +1 -0
  38. package/dist/instruction-delivery.js +103 -0
  39. package/dist/instruction-set-sync/index.d.ts +17 -0
  40. package/dist/instruction-set-sync/index.d.ts.map +1 -1
  41. package/dist/instruction-set-sync/index.js +44 -17
  42. package/dist/instruction-set-update/index.d.ts +2 -2
  43. package/dist/instruction-set-update/index.d.ts.map +1 -1
  44. package/dist/instruction-set-update/index.js +2 -2
  45. package/dist/instruction-sets-cli.d.ts.map +1 -1
  46. package/dist/instruction-sets-cli.js +19 -27
  47. package/dist/json-response.d.ts +1 -0
  48. package/dist/json-response.d.ts.map +1 -1
  49. package/dist/json-response.js +7 -0
  50. package/dist/preview-runtime.d.ts +2 -2
  51. package/dist/preview-runtime.d.ts.map +1 -1
  52. package/dist/preview-runtime.js +37 -7
  53. package/dist/task-export-workflow.d.ts +4 -0
  54. package/dist/task-export-workflow.d.ts.map +1 -0
  55. package/dist/task-export-workflow.js +46 -0
  56. package/dist/workbench-cli.js +1 -1
  57. package/package.json +1 -1
  58. package/src/dataset-commands.ts +92 -92
  59. package/src/dataset-delete-workflow.ts +72 -0
  60. package/src/dataset-lock-workflow.ts +74 -0
  61. package/src/dataset-readme-workflow.ts +80 -0
  62. package/src/dataset-row-workflow.ts +248 -0
  63. package/src/dataset-split-reads.ts +43 -0
  64. package/src/help.ts +29 -3
  65. package/src/hosted-boot.ts +16 -1
  66. package/src/hosted-harness-opencode.ts +67 -65
  67. package/src/hosted-loop-lifecycle.ts +4 -10
  68. package/src/hosted-loop.ts +6 -4
  69. package/src/hosted-model-cache.d.mts +2 -0
  70. package/src/hosted-model-cache.mjs +53 -0
  71. package/src/index.ts +10 -0
  72. package/src/instruction-delivery.ts +123 -0
  73. package/src/instruction-set-sync/index.ts +61 -14
  74. package/src/instruction-set-update/index.ts +4 -3
  75. package/src/instruction-sets-cli.ts +19 -27
  76. package/src/json-response.ts +8 -0
  77. package/src/preview-runtime.ts +40 -10
  78. package/src/task-export-workflow.ts +56 -0
  79. package/src/workbench-cli.ts +1 -1
  80. package/vendor/skills/orizu/references/cli-reference.md +34 -0
  81. package/vendor/skills/orizu/references/dataset-design.md +1 -1
  82. package/dist/dataset-edit-rows.d.ts +0 -10
  83. package/dist/dataset-edit-rows.d.ts.map +0 -1
  84. package/dist/dataset-edit-rows.js +0 -90
  85. package/src/dataset-edit-rows.ts +0 -124
@@ -51,8 +51,10 @@
51
51
 
52
52
  import { readFileSync } from 'fs'
53
53
 
54
+ import { refreshHostedModels } from '../src/hosted-model-cache.mjs'
55
+
54
56
  import { HOSTED_AGENT_NAME, HOSTED_AGENT_PROMPT } from './hosted-agent-prompt.js'
55
- import { findProvider, parseModelIdentity } from './provider-registry.js'
57
+ import { parseModelIdentity } from './provider-registry.js'
56
58
 
57
59
  import { HARNESS_NAMES } from './hosted-harness.js'
58
60
  import type {
@@ -152,17 +154,6 @@ export function ascendingId(prefix: 'ses' | 'msg' | 'prt', nowMs: number = Date.
152
154
  return `${prefix}_${hex}${randomBase62(14)}`
153
155
  }
154
156
 
155
- // -- Anthropic / OpenAI reasoning-effort mapping (port of bridge.py) ---------
156
- const ANTHROPIC_THINKING_BUDGETS: Record<string, number> = { high: 16_000, max: 31_999 }
157
- const ANTHROPIC_ADAPTIVE_THINKING_MODELS = new Set([
158
- 'claude-fable-5',
159
- 'claude-opus-4-6',
160
- 'claude-opus-4-7',
161
- 'claude-opus-4-8',
162
- 'claude-sonnet-4-6',
163
- ])
164
- const ANTHROPIC_ADAPTIVE_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
165
-
166
157
  /**
167
158
  * Split a possibly provider-qualified model string into provider + model id,
168
159
  * through THE registry parse (`parseModelIdentity`, ORI-2031): the provider is
@@ -201,34 +192,13 @@ export function buildPromptRequestBody(
201
192
  // Shared parse (see qualifyHostedModel): keeps the prompt path and the
202
193
  // ALI-1086 pre-prompt validation in lockstep by construction.
203
194
  const { providerId, modelId } = qualifyHostedModel(model)
204
- const modelSpec: Record<string, unknown> = { providerID: providerId, modelID: modelId }
205
-
206
- // Reasoning options are a property of the WIRE PROTOCOL, not of a provider
207
- // name (ORI-2031 row 7). `openai-chat` gets no options at all — identical to
208
- // today's fall-through for any provider the driver does not recognise, and
209
- // honest: Orizu has never measured chat-completions reasoning fields
210
- // (ORI-2032). A bare model id still resolves through qualifyHostedModel's
211
- // 'anthropic' default, so its options are unchanged.
212
- const protocol = findProvider(providerId)?.protocol ?? null
213
- if (reasoningEffort) {
214
- if (protocol === 'anthropic-messages') {
215
- if (ANTHROPIC_ADAPTIVE_THINKING_MODELS.has(modelId)) {
216
- const options: Record<string, unknown> = { thinking: { type: 'adaptive' } }
217
- if (ANTHROPIC_ADAPTIVE_EFFORTS.has(reasoningEffort)) {
218
- options.outputConfig = { effort: reasoningEffort }
219
- }
220
- modelSpec.options = options
221
- } else {
222
- const budget = ANTHROPIC_THINKING_BUDGETS[reasoningEffort]
223
- if (budget !== undefined) {
224
- modelSpec.options = { thinking: { type: 'enabled', budgetTokens: budget } }
225
- }
226
- }
227
- } else if (protocol === 'openai-responses') {
228
- modelSpec.options = { reasoningEffort, reasoningSummary: 'auto' }
229
- }
230
- }
231
- body.model = modelSpec
195
+ body.model = { providerID: providerId, modelID: modelId }
196
+ // Reasoning effort travels as the top-level `variant` (ORI-2526). OpenCode's
197
+ // prompt body accepts only providerID/modelID inside `model` and silently
198
+ // dropped the options we used to send there. Each model's variants map the
199
+ // effort onto its own request fields; an effort the model has no variant for
200
+ // is ignored by OpenCode, so no per-provider mapping lives here.
201
+ if (reasoningEffort) body.variant = reasoningEffort
232
202
  return body
233
203
  }
234
204
 
@@ -571,6 +541,8 @@ export function createOpenCodeHarness(options: OpenCodeHarnessOptions): AgentHar
571
541
  const userMessageIds = new Set<string>([opencodeMessageId])
572
542
  const pendingParts = new Map<string, Array<{ part: Record<string, unknown>; delta: unknown }>>()
573
543
  const emittedToolStates = new Set<string>()
544
+ const partTypes = new Map<string, string>()
545
+ const untypedDeltas = new Map<string, string[]>()
574
546
 
575
547
  const handlePart = (part: Record<string, unknown>, delta: unknown): HarnessEvent[] => {
576
548
  const partType = typeof part.type === 'string' ? part.type : ''
@@ -779,16 +751,50 @@ export function createOpenCodeHarness(options: OpenCodeHarnessOptions): AgentHar
779
751
  }
780
752
  }
781
753
  }
782
- } else if (eventType === 'message.part.updated') {
783
- const part = asRecord(props.part)
784
- const delta = props.delta
785
- const ocMsgId = typeof part.messageID === 'string' ? part.messageID : ''
786
- if (allowedAssistantMsgIds.has(ocMsgId)) {
787
- for (const ev of handlePart(part, delta)) yield ev
788
- } else if (ocMsgId) {
789
- const list = pendingParts.get(ocMsgId) ?? []
790
- list.push({ part, delta })
791
- pendingParts.set(ocMsgId, list)
754
+ } else if (eventType === 'message.part.updated' || eventType === 'message.part.delta') {
755
+ // Word-by-word text arrives as `message.part.delta` (ORI-2525), which
756
+ // names the part but not its type. Reasoning parts send deltas too,
757
+ // so a delta streams only once its part is known to be text. A delta
758
+ // can overtake its part's announcement (opencode #26924); it waits
759
+ // here until the announcement says what the part is.
760
+ const updates: Array<{ part: Record<string, unknown>; delta: unknown }> = []
761
+ if (eventType === 'message.part.updated') {
762
+ const part = asRecord(props.part)
763
+ const partId = typeof part.id === 'string' ? part.id : ''
764
+ updates.push({ part, delta: props.delta })
765
+ if (partId && typeof part.type === 'string') partTypes.set(partId, part.type)
766
+ const early = untypedDeltas.get(partId)
767
+ if (early) {
768
+ untypedDeltas.delete(partId)
769
+ const announced = typeof part.text === 'string' ? part.text : ''
770
+ // Replay unless the announced text already includes them.
771
+ if (part.type === 'text' && !announced.startsWith(early.join(''))) {
772
+ for (const delta of early) {
773
+ updates.push({ part: { type: 'text', id: partId, messageID: part.messageID }, delta })
774
+ }
775
+ }
776
+ }
777
+ } else {
778
+ const partId = typeof props.partID === 'string' ? props.partID : ''
779
+ const delta = props.delta
780
+ if (props.field !== 'text' || !partId || typeof delta !== 'string') continue
781
+ const partType = partTypes.get(partId)
782
+ if (partType === undefined) {
783
+ untypedDeltas.set(partId, [...(untypedDeltas.get(partId) ?? []), delta])
784
+ continue
785
+ }
786
+ if (partType !== 'text') continue
787
+ updates.push({ part: { type: 'text', id: partId, messageID: props.messageID }, delta })
788
+ }
789
+ for (const { part, delta } of updates) {
790
+ const ocMsgId = typeof part.messageID === 'string' ? part.messageID : ''
791
+ if (allowedAssistantMsgIds.has(ocMsgId)) {
792
+ for (const ev of handlePart(part, delta)) yield ev
793
+ } else if (ocMsgId) {
794
+ const list = pendingParts.get(ocMsgId) ?? []
795
+ list.push({ part, delta })
796
+ pendingParts.set(ocMsgId, list)
797
+ }
792
798
  }
793
799
  } else if (eventType === 'session.compacted') {
794
800
  // Compaction re-chains messages so subsequent assistant parentIDs no
@@ -910,16 +916,9 @@ function errorEvent(messageId: string, error: unknown): HarnessEvent {
910
916
 
911
917
  // -- Pre-prompt model validation (ALI-1086) -----------------------------------
912
918
  //
913
- // The pinned OpenCode (see OPENCODE_PINNED_VERSION) resolves model ids against
914
- // a catalog that is its BUNDLED snapshot merged with a runtime fetch of
915
- // https://models.dev/api.json (verified empirically on ALI-1086: fetched at
916
- // boot when reachable, cached to <cache>/opencode/models.json, refreshed when
917
- // stale). When models.dev is unreachable the bundled snapshot is stale
918
- // (predates claude-opus-4-8) and the FIRST prompt dies inside the SSE stream
919
- // with an opaque `run_failed: Model not found`. These helpers let the hosted
920
- // loop ask the RUNNING `opencode serve` — `GET /config/providers` is the
921
- // runtime's actual resolvable catalog — whether the requested model resolves,
922
- // BEFORE the prompt is posted, and fail fast naming the alternatives.
919
+ // Images bake a validated catalog. Orizu refreshes it before OpenCode's first
920
+ // read and disables its unchecked background writer (ORI-2513). This validation
921
+ // asks the running server whether the selected model resolves before prompting.
923
922
 
924
923
  /** provider id → model ids the RUNNING opencode instance can resolve. */
925
924
  export type OpenCodeModelCatalog = ReadonlyMap<string, readonly string[]>
@@ -1006,10 +1005,9 @@ export interface AwaitModelResolvableOptions {
1006
1005
  model: string
1007
1006
  fetchImpl?: HarnessFetch
1008
1007
  /**
1009
- * Total budget to wait for the model to become resolvable. OpenCode's
1010
- * models.dev refresh runs in the BACKGROUND at boot, so the catalog can be
1011
- * bundled-only for the first seconds after the server answers HTTP; polling
1012
- * bridges that window instead of failing a run the refresh would have fixed.
1008
+ * Budget for catalog readiness and transient server reads. Hosted startup
1009
+ * finishes its validated refresh before spawn; this polling remains a
1010
+ * compatibility guard for other callers and startup endpoint delays.
1013
1011
  */
1014
1012
  timeoutMs?: number
1015
1013
  pollMs?: number
@@ -1022,7 +1020,7 @@ const MODEL_RESOLVE_POLL_MS = 2_000
1022
1020
 
1023
1021
  /**
1024
1022
  * Decide whether the requested model is resolvable by the RUNNING opencode,
1025
- * polling briefly so the boot-time models.dev refresh has time to land.
1023
+ * polling briefly for catalog readiness or transient server failures.
1026
1024
  * Fail-open on validator infrastructure, with a DELIBERATE asymmetry on null
1027
1025
  * catalog reads:
1028
1026
  * - null BEFORE any successful read → `skipped` immediately (dead/absent
@@ -1207,7 +1205,11 @@ export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<Spawned
1207
1205
  const env: Record<string, string> = {
1208
1206
  OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
1209
1207
  ...(opts.env ?? {}),
1208
+ OPENCODE_DISABLE_MODELS_FETCH: '1',
1210
1209
  }
1210
+ const model = qualifyHostedModel(opts.model)
1211
+ env.OPENCODE_MODELS_PATH = await refreshHostedModels({ env: { ...process.env, ...env }, required: [`${model.providerId}/${model.modelId}`], fetchImpl: opts.fetchImpl, signal: opts.signal })
1212
+ opts.signal?.throwIfAborted()
1211
1213
  const cmd = ['opencode', 'serve', '--port', String(port)]
1212
1214
  const spawnImpl =
1213
1215
  opts.spawn ??
@@ -33,16 +33,10 @@ import type { HostedSessionOrigin } from './hosted-runtime-assets.js'
33
33
  *
34
34
  * PIN CONSTRAINT: the hosted OpenCode runtime is HARD-PINNED to
35
35
  * `opencode-ai@1.14.41` (`OPENCODE_PINNED_VERSION`, SSE-fragile — do not bump)
36
- * whose BUNDLED model catalog predates this model (newest bundled anthropic
37
- * opus: claude-opus-4-7). Verified empirically on ALI-1086: 1.14.41 DOES fetch
38
- * `https://models.dev/api.json` at boot when reachable (cached to
39
- * `~/.cache/opencode/models.json`, refreshed when stale, disabled only by
40
- * `OPENCODE_DISABLE_MODELS_FETCH`), so with models.dev on the sandbox egress
41
- * allowlist (#1392, `ORIZU_HOSTED_EGRESS_ALLOWLIST`) this id resolves in new
42
- * sandboxes. If the catalog fetch is blocked/stale anyway, the loop's
43
- * pre-prompt validation (`awaitOpenCodeModelResolvable`) fails the run fast,
44
- * naming the resolvable alternatives. The durable fix for the stale bundled
45
- * catalog is the ALI-929 harness swap.
36
+ * whose bundled catalog predates this model. Images bake a validated models.dev
37
+ * list into the runtime user's cache. Orizu refreshes that cache before spawning
38
+ * OpenCode, preserving baked models on failure; the native unchecked writer is
39
+ * disabled (ORI-2513). Pre-prompt validation still checks the running server.
46
40
  */
47
41
  export const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8'
48
42
 
@@ -231,6 +231,8 @@ export interface RunHostedLoopOptions {
231
231
  /** Value-free local diagnostics shared with the event sink. Production routes
232
232
  * this through hosted-boot's stderr seam; consumer failures are ignored. */
233
233
  onDiagnostic?: (message: string) => void
234
+ /** Report genuine harness activity to the session's progress watchdog. */
235
+ onProgress?: () => Promise<void>
234
236
  }
235
237
 
236
238
  export interface HostedLoopResult {
@@ -630,10 +632,9 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
630
632
 
631
633
  // Fail-fast model validation (ALI-1086): ask the RUNNING opencode whether
632
634
  // the requested model is resolvable BEFORE the first prompt. The pinned
633
- // opencode's bundled catalog is stale (predates claude-opus-4-8) and only
634
- // a boot-time models.dev fetch (#1392 allowlists it) refreshes it — when
635
- // that fetch is blocked, the first prompt used to die inside the SSE
636
- // stream with an opaque `run_failed: Model not found`. Now the run fails
635
+ // image catalog is baked and refreshed before spawn (ORI-2513). A model
636
+ // absent from that validated list still needs a clear error rather than
637
+ // dying inside the SSE stream with an opaque Model not found. The run fails
637
638
  // immediately with a structured artifact + an error naming the requested
638
639
  // id AND the resolvable alternatives. Fail-open on validator
639
640
  // infrastructure: an unreachable catalog endpoint (`skipped`) proceeds
@@ -784,6 +785,7 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
784
785
  }
785
786
  return
786
787
  }
788
+ if (event.kind !== 'question_auto_answered') await opts.onProgress?.()
787
789
  yield event
788
790
  }
789
791
  // A signal already durable before prompt startup can make a driver end
@@ -0,0 +1,2 @@
1
+ export function refreshHostedModels(options?: { env?: Record<string, string | undefined>; required?: string[]; strict?: boolean; fetchImpl?: typeof fetch; signal?: AbortSignal }): Promise<string>
2
+ export function bakeHostedModels(): Promise<string>
@@ -0,0 +1,53 @@
1
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ // The pinned runtime reads this file once: only replace it with a valid list containing the required models.
6
+ export async function refreshHostedModels({ env = process.env, required = [], strict = false, fetchImpl = fetch, signal } = {}) {
7
+ signal?.throwIfAborted()
8
+ const path = env.OPENCODE_MODELS_PATH || join(env.XDG_CACHE_HOME || join(env.HOME || homedir(), '.cache'), 'opencode', 'models.json')
9
+ const temporary = `${path}.${process.pid}.tmp`
10
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ const strings = (value, keys) => keys.every(key => value[key] === undefined || typeof value[key] === 'string')
12
+ const cost = value => value === undefined || (record(value) && ['input', 'output', 'cache_read', 'cache_write'].every(key => value[key] === undefined || Number.isFinite(value[key])) && cost(value.context_over_200k))
13
+ const providerOptions = value => value === undefined || (record(value) && strings(value, ['api', 'npm']) &&
14
+ (value.body === undefined || record(value.body)) && (value.headers === undefined || (record(value.headers) && Object.values(value.headers).every(header => typeof header === 'string'))))
15
+ const optionalModelFields = model => {
16
+ if (!strings(model, ['family']) || (model.status !== undefined && !['alpha', 'beta', 'deprecated', 'active'].includes(model.status)) || !cost(model.cost) || !providerOptions(model.provider)) return false
17
+ if (model.limit.input !== undefined && !Number.isFinite(model.limit.input)) return false
18
+ if (model.interleaved !== undefined && typeof model.interleaved !== 'boolean' && (!record(model.interleaved) || !['reasoning_content', 'reasoning_details'].includes(model.interleaved.field))) return false
19
+ if (model.modalities !== undefined && (!record(model.modalities) || !['input', 'output'].every(key => Array.isArray(model.modalities[key]) && model.modalities[key].every(value => ['text', 'audio', 'image', 'video', 'pdf'].includes(value))))) return false
20
+ if (model.experimental !== undefined && (!record(model.experimental) || (model.experimental.modes !== undefined && (!record(model.experimental.modes) || Object.entries(model.experimental.modes).some(([name, mode]) => !name || !record(mode) || !cost(mode.cost) || !providerOptions(mode.provider)))))) return false
21
+ return true
22
+ }
23
+ const identities = text => {
24
+ const catalog = JSON.parse(text)
25
+ if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) throw new Error('Invalid model catalog')
26
+ return Object.entries(catalog).flatMap(([provider, value]) => {
27
+ if (!value || value.id !== provider || !strings(value, ['api', 'npm']) || typeof value.name !== 'string' || !Array.isArray(value.env) || value.env.some(name => typeof name !== 'string') || typeof value.models !== 'object' || !value.models || Array.isArray(value.models)) throw new Error('Invalid model provider')
28
+ return Object.entries(value.models).map(([id, model]) => {
29
+ if (!model || model.id !== id || typeof model.name !== 'string' || typeof model.release_date !== 'string' || ['attachment', 'reasoning', 'tool_call'].some(key => typeof model[key] !== 'boolean') || (model.temperature !== undefined && typeof model.temperature !== 'boolean') || !Number.isFinite(model.limit?.context) || !Number.isFinite(model.limit?.output)) throw new Error('Invalid model entry')
30
+ if (!optionalModelFields(model)) throw new Error('Invalid optional model metadata')
31
+ return ['alpha', 'deprecated'].includes(model.status) ? null : `${provider}/${id}`
32
+ }).filter(Boolean)
33
+ })
34
+ }
35
+ try {
36
+ const response = await fetchImpl(`${env.OPENCODE_MODELS_URL || 'https://models.dev'}/api.json`, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000) })
37
+ if (!response.ok) throw new Error(`Model catalog download failed: ${response.status}`)
38
+ const text = await response.text()
39
+ const models = new Set(identities(text))
40
+ if (!models.size || required.some(id => !models.has(id))) throw new Error('Model catalog is missing required models')
41
+ await mkdir(dirname(path), { recursive: true })
42
+ await writeFile(temporary, text)
43
+ signal?.throwIfAborted()
44
+ await rename(temporary, path)
45
+ } catch (error) {
46
+ signal?.throwIfAborted()
47
+ if (strict) throw error
48
+ } finally { await rm(temporary, { force: true }) }
49
+ signal?.throwIfAborted()
50
+ return path
51
+ }
52
+
53
+ export const bakeHostedModels = () => refreshHostedModels({ strict: true, required: process.env.HOSTED_WORKSPACE_DEFAULT_MODEL ? [process.env.HOSTED_WORKSPACE_DEFAULT_MODEL] : ['openai/gpt-5.6', 'openai/gpt-6-luna', 'anthropic/claude-opus-4-8'] })
package/src/index.ts CHANGED
@@ -155,6 +155,8 @@ import {
155
155
  setDatasetReadme,
156
156
  createDatasetVersion,
157
157
  createDatasetSplitSet,
158
+ listDatasetSplitSets,
159
+ showDatasetSplitSet,
158
160
  editDatasetRows,
159
161
  deleteDatasetRows,
160
162
  deleteDataset,
@@ -2139,6 +2141,14 @@ export async function main(rawArgs = process.argv.slice(2)) {
2139
2141
  await createDatasetVersion()
2140
2142
  return
2141
2143
  }
2144
+ if (command === 'datasets' && subcommand === 'splits' && datasetsAction === 'list') {
2145
+ await listDatasetSplitSets()
2146
+ return
2147
+ }
2148
+ if (command === 'datasets' && subcommand === 'splits' && datasetsAction === 'show') {
2149
+ await showDatasetSplitSet()
2150
+ return
2151
+ }
2142
2152
  if (command === 'datasets' && subcommand === 'splits' && datasetsAction === 'create') {
2143
2153
  await createDatasetSplitSet()
2144
2154
  return
@@ -0,0 +1,123 @@
1
+ import { Cause, Effect, Exit } from 'effect'
2
+
3
+ import { authedFetch } from './http.js'
4
+ import { responsePayload } from './json-response.js'
5
+ import { stageSyncPayload, startSyncLockClaim, tryClaimSyncLock, type SyncDiskOptions, type SyncDiskResult, type SyncPayload, type SyncRequestPlan } from './instruction-set-sync/index.js'
6
+ import { applyUpdate, lockedProfileIdentity, makeUpdatePlan, readUpdateLock, type UpdatePlan, type UpdateResult } from './instruction-set-update/index.js'
7
+
8
+ // Loaded only through `await import(...)` from instruction-sets-cli, so CLI
9
+ // startup and the published loader never load Effect.
10
+
11
+ export interface Interrupted { interrupted: 130 | 143 }
12
+
13
+ // Existing file steps throw plain errors; keep them as the failure value so the
14
+ // CLI prints exactly the same message.
15
+ const attempt = <A>(operation: () => A) => Effect.try({ try: operation, catch: cause => cause })
16
+
17
+ function readPayload(path: string, action: string) {
18
+ return Effect.tryPromise({
19
+ // The body stays unchecked here, as before: stageSyncPayload and makeUpdatePlan validate it.
20
+ // SAFETY: their existing error messages for malformed bodies are part of the CLI's output.
21
+ try: async signal => await responsePayload(await authedFetch(path, { method: 'GET', signal }), action) as unknown as SyncPayload,
22
+ catch: cause => cause,
23
+ })
24
+ }
25
+
26
+ // Waiting for another process's lock is the only interruptible part: nothing
27
+ // is held yet. Publishing runs to completion once the lock is ours.
28
+ function holdSyncLock<A>(appRoot: string, use: Effect.Effect<A, unknown>) {
29
+ const acquire = Effect.gen(function*() {
30
+ const claim = yield* attempt(() => startSyncLockClaim(appRoot))
31
+ while (true) {
32
+ const claimed = yield* attempt(() => tryClaimSyncLock(claim))
33
+ if (typeof claimed === 'function') return claimed
34
+ yield* Effect.interruptible(Effect.sleep(claimed))
35
+ }
36
+ })
37
+ return Effect.acquireUseRelease(acquire, () => use, release => Effect.sync(release))
38
+ }
39
+
40
+ // Stage the version, publish it under the lock, and always remove an
41
+ // unpublished stage, including after Ctrl-C or SIGTERM.
42
+ function deliverPayload(...args: Parameters<typeof stageSyncPayload>): Effect.Effect<SyncDiskResult, unknown> {
43
+ return Effect.acquireUseRelease(
44
+ attempt(() => stageSyncPayload(...args)),
45
+ staged => holdSyncLock(staged.appRoot, attempt(staged.publish)).pipe(
46
+ Effect.map(warnings => ({ destination: staged.destination, wasPresent: staged.wasPresent, warnings })),
47
+ ),
48
+ staged => Effect.sync(staged.discard),
49
+ )
50
+ }
51
+
52
+ async function runOrThrow<A>(program: Effect.Effect<A, unknown>, signal: AbortSignal): Promise<A> {
53
+ const exit = await Effect.runPromiseExit(program, { signal })
54
+ if (Exit.isSuccess(exit)) return exit.value
55
+ throw Cause.squash(exit.cause)
56
+ }
57
+
58
+ // `once`: a second signal falls through to Node's default kill.
59
+ async function runDelivery<A>(program: (signal: AbortSignal) => Effect.Effect<A, unknown>): Promise<A | Interrupted> {
60
+ const controller = new AbortController()
61
+ let interrupted: Interrupted['interrupted'] | undefined
62
+ const handleInterrupt = () => { interrupted ??= 130; controller.abort() }
63
+ const handleTerminate = () => { interrupted ??= 143; controller.abort() }
64
+ process.once('SIGINT', handleInterrupt)
65
+ process.once('SIGTERM', handleTerminate)
66
+ try {
67
+ return await runOrThrow(program(controller.signal), controller.signal)
68
+ } catch (error) {
69
+ if (interrupted !== undefined) return { interrupted }
70
+ throw error
71
+ } finally {
72
+ process.removeListener('SIGINT', handleInterrupt)
73
+ process.removeListener('SIGTERM', handleTerminate)
74
+ }
75
+ }
76
+
77
+ export function syncInstructionSet(out: string, project: string, plan: SyncRequestPlan, options: SyncDiskOptions) {
78
+ return runDelivery(() => Effect.gen(function*() {
79
+ const payload = yield* readPayload(plan.path, 'Instruction sets sync')
80
+ const result = yield* deliverPayload(out, project, plan, payload, options)
81
+ return { payload, result }
82
+ }))
83
+ }
84
+
85
+ export function updateInstructionSets(
86
+ out: string,
87
+ project: string,
88
+ isApproved: boolean,
89
+ noSync: boolean,
90
+ ): Promise<{ plan: UpdatePlan; result: UpdateResult } | Interrupted> {
91
+ const program = (signal: AbortSignal) => Effect.gen(function*() {
92
+ const lock = yield* attempt(() => readUpdateLock(out, project))
93
+ const payloads: SyncPayload[] = []
94
+ for (const [setSlug, set] of Object.entries(lock.instructionSets)) {
95
+ const barePath = `/api/cli/instruction-sets/${encodeURIComponent(setSlug)}/sync?project=${encodeURIComponent(project)}`
96
+ payloads.push(yield* readPayload(barePath, 'Instruction sets update'))
97
+ for (const profileSlugValue of Object.keys(set.profiles)) {
98
+ const identity = yield* attempt(() => lockedProfileIdentity(out, setSlug, profileSlugValue))
99
+ const query = new URLSearchParams({ project, profile: identity })
100
+ const path = `/api/cli/instruction-sets/${encodeURIComponent(setSlug)}/sync?${query.toString()}`
101
+ payloads.push(yield* readPayload(path, 'Instruction sets update'))
102
+ }
103
+ }
104
+ const plan = yield* attempt(() => makeUpdatePlan(project, lock, payloads))
105
+ if (!isApproved) return { plan, result: { absent: [], warnings: [] } }
106
+ // applyUpdate's own steps are not interruptible; each version it syncs is
107
+ // a nested delivery that the shared signal interrupts and cleans up.
108
+ const result = yield* Effect.uninterruptible(Effect.tryPromise({
109
+ try: () => applyUpdate(out, project, plan, noSync, undefined,
110
+ (...args) => runOrThrow(deliverPayload(...args), signal)),
111
+ catch: cause => cause,
112
+ }))
113
+ return { plan, result }
114
+ })
115
+ // --no-sync stages nothing to clean up, so keep Node's default signal
116
+ // handling: a signal stops it at once instead of being held until the
117
+ // update has already landed.
118
+ if (noSync) {
119
+ const signal = new AbortController().signal
120
+ return runOrThrow(program(signal), signal)
121
+ }
122
+ return runDelivery(program)
123
+ }
@@ -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,