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
@@ -0,0 +1,72 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
4
+ import { authedFetch } from './http.js'
5
+ import { parseJsonResponse } from './json-response.js'
6
+
7
+ // Loaded only through `await import(...)` from dataset-commands, like append.
8
+
9
+ const MAY_BE_DELETED = 'The dataset may already be deleted: check with `orizu datasets list --status all` before retrying.'
10
+
11
+ const DeleteReply = Schema.Struct({ dataset: Schema.Struct({ id: Schema.String }) })
12
+
13
+ // A refusal (4xx) means nothing was deleted. After any other failure once the
14
+ // request was sent, the delete may have committed, and it cannot be undone.
15
+ class DeleteFailure extends Error {
16
+ constructor(message: string, readonly mayBeDeleted: boolean) { super(message) }
17
+ }
18
+
19
+ function deleteRequest(datasetId: string, project: string | null, timeoutMs: number) {
20
+ return Effect.suspend(() => {
21
+ let sent = false
22
+ let timedOut = false
23
+ return Effect.tryPromise({
24
+ try: async signal => {
25
+ const request = new AbortController()
26
+ const abort = () => request.abort()
27
+ const timer = setTimeout(() => { timedOut = true; request.abort() }, timeoutMs)
28
+ if (signal.aborted) abort()
29
+ else signal.addEventListener('abort', abort, { once: true })
30
+ try {
31
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}`, {
32
+ method: 'DELETE',
33
+ signal: request.signal,
34
+ }, () => { sent = true })
35
+ if (!response.ok) {
36
+ const message = `Delete failed: ${await response.text()}`
37
+ // A dependency refusal: archiving keeps the dataset and its references.
38
+ if (response.status === 409) {
39
+ const archive = `orizu datasets archive ${datasetId}${project ? ` --project ${project}` : ''}`
40
+ throw new DeleteFailure(`${message}\nArchive it instead: \`${archive}\``, false)
41
+ }
42
+ throw new DeleteFailure(message, response.status < 400 || response.status >= 500)
43
+ }
44
+ const data = await parseJsonResponse<unknown>(response, 'Dataset delete')
45
+ const decoded = Schema.decodeUnknownResult(DeleteReply)(data)
46
+ if (Result.isFailure(decoded)) {
47
+ throw new DeleteFailure(`Dataset delete returned an unexpected response (status ${response.status}).`, true)
48
+ }
49
+ return decoded.success.dataset
50
+ } finally {
51
+ clearTimeout(timer)
52
+ signal.removeEventListener('abort', abort)
53
+ request.abort()
54
+ }
55
+ },
56
+ catch: cause => {
57
+ if (timedOut) return new DeleteFailure('DATASET_TRANSFER_TIMEOUT: Dataset delete exceeded its deadline', true)
58
+ if (cause instanceof DeleteFailure) return cause
59
+ return new DeleteFailure(cause instanceof Error ? cause.message : String(cause), sent)
60
+ },
61
+ })
62
+ }).pipe(Effect.mapError(failure => new Error(failure.mayBeDeleted ? `${failure.message}\n${MAY_BE_DELETED}` : failure.message)))
63
+ }
64
+
65
+ // Runs after the typed confirmation. Nothing to clean up, so signals keep
66
+ // Node's default handling.
67
+ // `project` (team/project), when known, goes into the archive hint.
68
+ export async function deleteDataset(datasetId: string, project: string | null): Promise<{ id: string }> {
69
+ const exit = await Effect.runPromiseExit(deleteRequest(datasetId, project, datasetTransferTimeoutMs()))
70
+ if (Exit.isSuccess(exit)) return exit.value
71
+ throw Cause.squash(exit.cause)
72
+ }
@@ -0,0 +1,74 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
4
+ import { authedFetch } from './http.js'
5
+ import { parseJsonResponse } from './json-response.js'
6
+
7
+ // Loaded only through `await import(...)` from dataset-commands, like append.
8
+
9
+ const MAY_BE_LOCKED = 'The dataset may already be locked: running `orizu datasets lock` again is safe and shows its current state.'
10
+
11
+ const LockedDataset = Schema.Struct({
12
+ id: Schema.String,
13
+ name: Schema.String,
14
+ rowCount: Schema.Number,
15
+ lockedAt: Schema.String,
16
+ lockedBy: Schema.NullOr(Schema.String),
17
+ })
18
+ const LockReply = Schema.Struct({ dataset: LockedDataset })
19
+ export type LockedDataset = typeof LockedDataset.Type
20
+
21
+ // A refusal (4xx) means nothing was locked. After any other failure once the
22
+ // request was sent, the lock may have committed; locking again is harmless.
23
+ class LockFailure extends Error {
24
+ constructor(message: string, readonly mayBeLocked: boolean) { super(message) }
25
+ }
26
+
27
+ function lockRequest(datasetId: string, reason: string | null, timeoutMs: number) {
28
+ return Effect.suspend(() => {
29
+ let sent = false
30
+ let timedOut = false
31
+ return Effect.tryPromise({
32
+ try: async signal => {
33
+ const request = new AbortController()
34
+ const abort = () => request.abort()
35
+ const timer = setTimeout(() => { timedOut = true; request.abort() }, timeoutMs)
36
+ if (signal.aborted) abort()
37
+ else signal.addEventListener('abort', abort, { once: true })
38
+ try {
39
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/lock`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify(reason ? { reason } : {}),
43
+ signal: request.signal,
44
+ }, () => { sent = true })
45
+ if (!response.ok) {
46
+ throw new LockFailure(`Lock failed: ${await response.text()}`, response.status < 400 || response.status >= 500)
47
+ }
48
+ const data = await parseJsonResponse<unknown>(response, 'Dataset lock')
49
+ const decoded = Schema.decodeUnknownResult(LockReply)(data)
50
+ if (Result.isFailure(decoded)) {
51
+ throw new LockFailure(`Dataset lock returned an unexpected response (status ${response.status}).`, true)
52
+ }
53
+ return decoded.success.dataset
54
+ } finally {
55
+ clearTimeout(timer)
56
+ signal.removeEventListener('abort', abort)
57
+ request.abort()
58
+ }
59
+ },
60
+ catch: cause => {
61
+ if (timedOut) return new LockFailure('DATASET_TRANSFER_TIMEOUT: Dataset lock exceeded its deadline', true)
62
+ if (cause instanceof LockFailure) return cause
63
+ return new LockFailure(cause instanceof Error ? cause.message : String(cause), sent)
64
+ },
65
+ })
66
+ }).pipe(Effect.mapError(failure => new Error(failure.mayBeLocked ? `${failure.message}\n${MAY_BE_LOCKED}` : failure.message)))
67
+ }
68
+
69
+ // Nothing to clean up, so signals keep Node's default handling.
70
+ export async function lockDataset(datasetId: string, reason: string | null): Promise<LockedDataset> {
71
+ const exit = await Effect.runPromiseExit(lockRequest(datasetId, reason, datasetTransferTimeoutMs()))
72
+ if (Exit.isSuccess(exit)) return exit.value
73
+ throw Cause.squash(exit.cause)
74
+ }
@@ -0,0 +1,80 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
4
+ import { authedFetch } from './http.js'
5
+ import { parseJsonResponse } from './json-response.js'
6
+
7
+ // Loaded only through `await import(...)` from dataset-commands, like delete.
8
+
9
+ const MAY_BE_SAVED = "The README may already be saved as a new version: check the dataset's README tab before saving again."
10
+ const SAVED_AT_SAME_TIME = 'Another README save for this dataset happened at the same time, so yours was not saved. Run the command again to save it as the next version.'
11
+
12
+ const SaveReply = Schema.Struct({
13
+ version: Schema.Struct({
14
+ id: Schema.String,
15
+ dataset_id: Schema.String,
16
+ version_num: Schema.Number,
17
+ created_at: Schema.String,
18
+ }),
19
+ })
20
+
21
+ export type SavedReadmeVersion = typeof SaveReply.Type['version']
22
+
23
+ // A refusal (4xx) means nothing was saved. After any other failure once the
24
+ // request was sent, a new version may exist; saving again would add another.
25
+ class SaveFailure extends Error {
26
+ constructor(message: string, readonly mayBeSaved: boolean) { super(message) }
27
+ }
28
+
29
+ function saveRequest(datasetId: string, markdown: string, timeoutMs: number) {
30
+ return Effect.suspend(() => {
31
+ let sent = false
32
+ let timedOut = false
33
+ return Effect.tryPromise({
34
+ try: async signal => {
35
+ const request = new AbortController()
36
+ const abort = () => request.abort()
37
+ const timer = setTimeout(() => { timedOut = true; request.abort() }, timeoutMs)
38
+ if (signal.aborted) abort()
39
+ else signal.addEventListener('abort', abort, { once: true })
40
+ try {
41
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/readme`, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ markdown }),
45
+ signal: request.signal,
46
+ }, () => { sent = true })
47
+ if (!response.ok) {
48
+ const message = `Failed to save dataset README: ${await response.text()}`
49
+ // The unique version number refused a save that lost a race.
50
+ if (response.status === 409) throw new SaveFailure(`${message}\n${SAVED_AT_SAME_TIME}`, false)
51
+ throw new SaveFailure(message, response.status < 400 || response.status >= 500)
52
+ }
53
+ const data = await parseJsonResponse<unknown>(response, 'Dataset README save')
54
+ const decoded = Schema.decodeUnknownResult(SaveReply)(data)
55
+ if (Result.isFailure(decoded)) {
56
+ throw new SaveFailure(`Dataset README save returned an unexpected response (status ${response.status}).`, true)
57
+ }
58
+ return decoded.success.version
59
+ } finally {
60
+ clearTimeout(timer)
61
+ signal.removeEventListener('abort', abort)
62
+ request.abort()
63
+ }
64
+ },
65
+ catch: cause => {
66
+ if (timedOut) return new SaveFailure('DATASET_TRANSFER_TIMEOUT: README save exceeded its deadline', sent)
67
+ if (cause instanceof SaveFailure) return cause
68
+ return new SaveFailure(cause instanceof Error ? cause.message : String(cause), sent)
69
+ },
70
+ })
71
+ }).pipe(Effect.mapError(failure => new Error(failure.mayBeSaved ? `${failure.message}\n${MAY_BE_SAVED}` : failure.message)))
72
+ }
73
+
74
+ // One request, never retried: a retry after an unknown outcome could add a
75
+ // duplicate version.
76
+ export async function saveDatasetReadme(datasetId: string, markdown: string): Promise<SavedReadmeVersion> {
77
+ const exit = await Effect.runPromiseExit(saveRequest(datasetId, markdown, datasetTransferTimeoutMs()))
78
+ if (Exit.isSuccess(exit)) return exit.value
79
+ throw Cause.squash(exit.cause)
80
+ }
@@ -0,0 +1,248 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
4
+ import { extractErrorMessage } from './error-response.js'
5
+ import { parseDatasetFile } from './file-parser.js'
6
+ import { authedFetch } from './http.js'
7
+ import { parseJsonResponse } from './json-response.js'
8
+
9
+ // Loaded only through `await import(...)` from dataset-commands, like append.
10
+
11
+ const MAX_REQUEST_BYTES = 3 * 1024 * 1024
12
+ const MAX_CHUNK_ROWS = 10
13
+ const EMPTY_REQUEST_BYTES = Buffer.byteLength('{"rows":[]}', 'utf8')
14
+
15
+ type DatasetRowEdit = Record<string, unknown> & { id: string }
16
+
17
+ // 'rejected': the server answered with a refusal, so nothing was written.
18
+ // 'unknown': the request was sent but no trustworthy answer came back.
19
+ type Outcome = 'rejected' | 'unknown' | 'not_sent'
20
+ class RowsRequestFailure extends Error {
21
+ readonly _tag = 'RowsRequestFailure'
22
+ constructor(message: string, readonly outcome: Outcome) { super(message) }
23
+ }
24
+
25
+ const Dataset = Schema.Struct({ id: Schema.String, name: Schema.String, rowCount: Schema.Number })
26
+ const EditAcknowledgement = Schema.Struct({ dataset: Dataset, updatedCount: Schema.Number })
27
+ type EditAcknowledgement = typeof EditAcknowledgement.Type
28
+
29
+ export interface DatasetRowsOptions {
30
+ datasetId: string
31
+ json: boolean
32
+ printJson: (value: Record<string, unknown>) => void
33
+ printLine: (value: string) => void
34
+ sanitize: (value: string) => string
35
+ }
36
+
37
+ function normalizeRows(rows: Array<Record<string, unknown>>): DatasetRowEdit[] {
38
+ if (rows.length === 0) {
39
+ throw new Error('Dataset edit file must contain at least one row')
40
+ }
41
+
42
+ const seenRowIds = new Set<string>()
43
+ return rows.map((row, index) => {
44
+ const rowId = typeof row.id === 'string' ? row.id.trim() : ''
45
+ if (!rowId) {
46
+ throw new Error(`Dataset edit file rows[${index}] must include a non-empty string id`)
47
+ }
48
+ if (seenRowIds.has(rowId)) {
49
+ throw new Error(`Dataset edit file rows[${index}].id duplicates a previous row id`)
50
+ }
51
+ seenRowIds.add(rowId)
52
+ return { ...row, id: rowId }
53
+ })
54
+ }
55
+
56
+ function chunkRows(rows: DatasetRowEdit[], sanitize: (value: string) => string): DatasetRowEdit[][] {
57
+ const chunks: DatasetRowEdit[][] = []
58
+ let currentChunk: DatasetRowEdit[] = []
59
+ let currentBytes = EMPTY_REQUEST_BYTES
60
+
61
+ for (const row of rows) {
62
+ const rowBytes = Buffer.byteLength(JSON.stringify(row), 'utf8')
63
+ const framedRowBytes = rowBytes + (currentChunk.length > 0 ? 1 : 0)
64
+ if (
65
+ currentChunk.length > 0 &&
66
+ (currentChunk.length >= MAX_CHUNK_ROWS || currentBytes + framedRowBytes > MAX_REQUEST_BYTES)
67
+ ) {
68
+ chunks.push(currentChunk)
69
+ currentChunk = []
70
+ currentBytes = EMPTY_REQUEST_BYTES
71
+ }
72
+
73
+ const singleRowRequestBytes = EMPTY_REQUEST_BYTES + rowBytes
74
+ if (singleRowRequestBytes > MAX_REQUEST_BYTES) {
75
+ throw new Error(
76
+ `Dataset edit row ${sanitize(row.id)} is ${singleRowRequestBytes} bytes, ` +
77
+ `which exceeds the ${MAX_REQUEST_BYTES}-byte edit request limit`
78
+ )
79
+ }
80
+
81
+ currentChunk.push(row)
82
+ currentBytes += rowBytes + (currentChunk.length > 1 ? 1 : 0)
83
+ }
84
+
85
+ if (currentChunk.length > 0) chunks.push(currentChunk)
86
+ return chunks
87
+ }
88
+
89
+ // One rows request with a deadline that covers the whole reply. A deadline,
90
+ // a lost connection or a broken reply after sending is an unknown outcome.
91
+ function rowsRequest<A>(
92
+ datasetId: string,
93
+ method: 'PATCH' | 'DELETE',
94
+ body: unknown,
95
+ label: string,
96
+ timeoutMs: number,
97
+ read: (response: Response) => Promise<A>,
98
+ ) {
99
+ return Effect.suspend(() => {
100
+ let sent = false
101
+ let timedOut = false
102
+ return Effect.tryPromise({
103
+ try: async signal => {
104
+ const request = new AbortController()
105
+ const abort = () => request.abort()
106
+ const timer = setTimeout(() => { timedOut = true; request.abort() }, timeoutMs)
107
+ if (signal.aborted) abort()
108
+ else signal.addEventListener('abort', abort, { once: true })
109
+ try {
110
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/rows`, {
111
+ method,
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify(body),
114
+ signal: request.signal,
115
+ }, () => { sent = true })
116
+ return await read(response)
117
+ } finally {
118
+ clearTimeout(timer)
119
+ signal.removeEventListener('abort', abort)
120
+ request.abort()
121
+ }
122
+ },
123
+ catch: cause => {
124
+ if (timedOut) {
125
+ return sent
126
+ ? new RowsRequestFailure(`DATASET_TRANSFER_TIMEOUT: ${label} exceeded its deadline`, 'unknown')
127
+ : new RowsRequestFailure(`DATASET_TRANSFER_TIMEOUT: ${label} exceeded its deadline before the request was sent`, 'not_sent')
128
+ }
129
+ if (cause instanceof RowsRequestFailure) return cause
130
+ return new RowsRequestFailure(cause instanceof Error ? cause.message : String(cause), sent ? 'unknown' : 'not_sent')
131
+ },
132
+ })
133
+ })
134
+ }
135
+
136
+ function refusalOutcome(status: number): Outcome {
137
+ return status >= 400 && status < 500 ? 'rejected' : 'unknown'
138
+ }
139
+
140
+ function editProgram(options: DatasetRowsOptions & { file: string }, timeoutMs: number) {
141
+ return Effect.gen(function*() {
142
+ const chunks = yield* Effect.try({
143
+ try: () => chunkRows(normalizeRows(parseDatasetFile(options.file).rows), options.sanitize),
144
+ catch: cause => cause,
145
+ })
146
+ let totalUpdated = 0
147
+ // normalizeRows refuses an empty file, so the loop always sets this.
148
+ let lastResult!: EditAcknowledgement
149
+
150
+ for (let index = 0; index < chunks.length; index += 1) {
151
+ const chunk = chunks[index]
152
+ if (!options.json && chunks.length > 1) {
153
+ options.printLine(`Updating chunk ${index + 1}/${chunks.length} (${chunk.length} rows)...`)
154
+ }
155
+ const acknowledged = yield* Effect.result(rowsRequest(options.datasetId, 'PATCH', { rows: chunk }, 'Dataset row edit', timeoutMs, async response => {
156
+ if (!response.ok) {
157
+ throw new RowsRequestFailure(`HTTP ${response.status}: ${await extractErrorMessage(response)}`, refusalOutcome(response.status))
158
+ }
159
+ const data: unknown = await response.json()
160
+ const decoded = Schema.decodeUnknownResult(EditAcknowledgement)(data)
161
+ if (Result.isFailure(decoded)) {
162
+ throw new RowsRequestFailure(`Server returned an unreadable acknowledgement for a ${chunk.length}-row chunk`, 'unknown')
163
+ }
164
+ if (!Number.isInteger(decoded.success.updatedCount) || decoded.success.updatedCount !== chunk.length) {
165
+ throw new RowsRequestFailure(`Server reported ${String(decoded.success.updatedCount)} updated rows for a ${chunk.length}-row chunk`, 'unknown')
166
+ }
167
+ return decoded.success
168
+ }))
169
+ if (Result.isFailure(acknowledged)) {
170
+ const { message, outcome } = acknowledged.failure
171
+ const advice = outcome === 'unknown'
172
+ ? 'This chunk may already be applied: check these rows in the dataset before retrying.'
173
+ : 'Retry with a file containing only the remaining row edits.'
174
+ return yield* Effect.fail(new Error(
175
+ `Chunk ${index + 1}/${chunks.length} failed: ${message}\n` +
176
+ `${totalUpdated} rows from ${index} chunk(s) were already updated. ${advice}`
177
+ ))
178
+ }
179
+ totalUpdated += acknowledged.success.updatedCount
180
+ lastResult = acknowledged.success
181
+ }
182
+
183
+ if (options.json) {
184
+ options.printJson({ dataset: lastResult.dataset, updatedCount: totalUpdated })
185
+ return
186
+ }
187
+ options.printLine(
188
+ `Updated ${totalUpdated} rows in dataset ${options.sanitize(lastResult.dataset.name)} ` +
189
+ `(${options.sanitize(lastResult.dataset.id)}). Current row count: ${lastResult.dataset.rowCount}`
190
+ )
191
+ })
192
+ }
193
+
194
+ function deleteProgram(options: DatasetRowsOptions & { rowIds: string[] }, timeoutMs: number) {
195
+ return Effect.gen(function*() {
196
+ const deleted = yield* rowsRequest(options.datasetId, 'DELETE', { rowIds: options.rowIds }, 'Dataset row removal', timeoutMs, async response => {
197
+ if (!response.ok) {
198
+ throw new RowsRequestFailure(await response.text(), refusalOutcome(response.status))
199
+ }
200
+ return parseJsonResponse<{
201
+ dataset: { id: string; name: string; rowCount: number }
202
+ deletedCount: number
203
+ }>(response, 'Dataset delete rows')
204
+ }).pipe(Effect.mapError(({ message, outcome }) => new Error(
205
+ `Delete rows failed: ${message}` +
206
+ (outcome === 'unknown' ? '\nThe rows may already be removed: check the dataset before retrying.' : '')
207
+ )))
208
+ if (options.json) {
209
+ options.printJson({ dataset: deleted.dataset, deletedCount: deleted.deletedCount })
210
+ return
211
+ }
212
+ options.printLine(
213
+ `Deleted ${deleted.deletedCount} rows from dataset ${options.sanitize(deleted.dataset.name)} (${options.sanitize(deleted.dataset.id)}). New row count: ${deleted.dataset.rowCount}`
214
+ )
215
+ })
216
+ }
217
+
218
+ // Ctrl-C or SIGTERM aborts the request in flight. Returns the exit code for
219
+ // an interruption; other failures throw their original error.
220
+ async function runRowsCommand(name: string, program: (timeoutMs: number) => Effect.Effect<void, unknown>): Promise<0 | 130 | 143> {
221
+ const timeoutMs = datasetTransferTimeoutMs()
222
+ const controller = new AbortController()
223
+ let interrupted: 130 | 143 | undefined
224
+ const interrupt = () => { interrupted ??= 130; controller.abort() }
225
+ const terminate = () => { interrupted ??= 143; controller.abort() }
226
+ process.on('SIGINT', interrupt)
227
+ process.on('SIGTERM', terminate)
228
+ try {
229
+ const exit = await Effect.runPromiseExit(program(timeoutMs), { signal: controller.signal })
230
+ if (interrupted !== undefined) {
231
+ process.stderr.write(`${name} interrupted. The request in flight may already be applied: check the dataset before retrying.\n`)
232
+ return interrupted
233
+ }
234
+ if (Exit.isFailure(exit)) throw Cause.squash(exit.cause)
235
+ return 0
236
+ } finally {
237
+ process.removeListener('SIGINT', interrupt)
238
+ process.removeListener('SIGTERM', terminate)
239
+ }
240
+ }
241
+
242
+ export function editDatasetRows(options: DatasetRowsOptions & { file: string }): Promise<0 | 130 | 143> {
243
+ return runRowsCommand('Dataset row edit', timeoutMs => editProgram(options, timeoutMs))
244
+ }
245
+
246
+ export function deleteDatasetRows(options: DatasetRowsOptions & { rowIds: string[] }): Promise<0 | 130 | 143> {
247
+ return runRowsCommand('Dataset row removal', timeoutMs => deleteProgram(options, timeoutMs))
248
+ }
@@ -0,0 +1,43 @@
1
+ import { Effect, Result, Schema } from 'effect'
2
+ import { writeFileSync } from 'node:fs'
3
+
4
+ import { authedFetch } from './http.js'
5
+ import { extractErrorMessage } from './error-response.js'
6
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
7
+
8
+ const SplitExport = Schema.Struct({
9
+ split_set_id: Schema.String, dataset_version_id: Schema.String,
10
+ name: Schema.String, strategy: Schema.String, seed: Schema.NullOr(Schema.Number), metadata: Schema.Unknown,
11
+ partitions: Schema.Array(Schema.Struct({ name: Schema.String, row_ids: Schema.Array(Schema.String), metadata: Schema.Unknown })),
12
+ })
13
+ const SplitList = Schema.Struct({
14
+ dataset_version_id: Schema.String,
15
+ split_sets: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String, strategy: Schema.String, seed: Schema.NullOr(Schema.Number), partitions: Schema.Array(Schema.Struct({ name: Schema.String, row_count: Schema.Number })) })),
16
+ })
17
+
18
+ async function read<A>(path: string, decode: (value: unknown) => A): Promise<A> {
19
+ const result = await Effect.runPromise(Effect.result(Effect.tryPromise({
20
+ try: async signal => {
21
+ const response = await authedFetch(path, { signal })
22
+ if (!response.ok) throw new Error(await extractErrorMessage(response))
23
+ try { return decode(await response.json()) } catch (cause) { throw new Error('Dataset split response is incomplete or unreadable', { cause }) }
24
+ }, catch: cause => cause instanceof Error ? cause : new Error('Dataset split read failed', { cause }),
25
+ }).pipe(Effect.timeoutOrElse({ duration: datasetTransferTimeoutMs(), orElse: () => Effect.fail(new Error('Dataset split read exceeded its deadline')) }))))
26
+ if (Result.isFailure(result)) throw result.failure
27
+ return result.success
28
+ }
29
+
30
+ export function listDatasetSplits(versionId: string) { return read(`/api/cli/dataset-versions/${encodeURIComponent(versionId)}/split-sets`, Schema.decodeUnknownSync(SplitList)) }
31
+ export function showDatasetSplit(setId: string) { return read(`/api/cli/split-sets/${encodeURIComponent(setId)}`, Schema.decodeUnknownSync(SplitExport)) }
32
+
33
+ export async function downloadPinnedDatasetSplit(versionId: string, setId: string, split: string, format: string, filename: string) {
34
+ const query = new URLSearchParams({ split_set: setId, split, format })
35
+ const result = await Effect.runPromise(Effect.result(Effect.tryPromise({
36
+ try: async signal => {
37
+ const response = await authedFetch(`/api/cli/dataset-versions/${encodeURIComponent(versionId)}/download?${query}`, { signal })
38
+ if (!response.ok) throw new Error(`Download failed: ${await extractErrorMessage(response)}`)
39
+ return new Uint8Array(await response.arrayBuffer())
40
+ }, catch: cause => cause instanceof Error ? cause : new Error('Dataset split download failed', { cause }),
41
+ }).pipe(Effect.timeoutOrElse({ duration: datasetTransferTimeoutMs(), orElse: () => Effect.fail(new Error('Dataset split download exceeded its deadline')) }), Effect.flatMap(bytes => Effect.try({ try: () => writeFileSync(filename, bytes, { mode: 0o600 }), catch: cause => new Error('Could not save dataset split download', { cause }) })))))
42
+ if (Result.isFailure(result)) throw result.failure
43
+ }
package/src/help.ts CHANGED
@@ -948,17 +948,43 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
948
948
  summary: 'Create a dataset version.',
949
949
  group: 'Datasets',
950
950
  },
951
+ {
952
+ path: ['datasets', 'splits', 'list'],
953
+ usage: 'orizu datasets splits list <datasetVersionId> [--json]',
954
+ summary: 'Discover saved split sets, partition names and row counts for an immutable dataset version.',
955
+ group: 'Datasets',
956
+ options: [{ name: '--json', help: 'Print saved split sets and partition row counts as JSON.' }],
957
+ examples: ['orizu datasets splits list DATASET_VERSION_ID --json'],
958
+ },
959
+ {
960
+ path: ['datasets', 'splits', 'show'],
961
+ usage: 'orizu datasets splits show <splitSetId> [--json]',
962
+ summary: 'Inspect the pinned version, set identity and exact ordered row membership of every partition, including empty splits.',
963
+ group: 'Datasets',
964
+ options: [{ name: '--json', help: 'Export reusable assignments: name, strategy, seed, metadata and partitions [{name, row_ids}], with source identity for inspection. Import uses the explicit target version.' }],
965
+ examples: ['orizu datasets splits show SPLIT_SET_ID --json > split.json', 'orizu datasets splits create TARGET_DATASET_VERSION_ID --from-file split.json --json'],
966
+ },
951
967
  {
952
968
  path: ['datasets', 'splits', 'create'],
953
969
  usage: 'orizu datasets splits create <datasetVersionId> [--from-file <split.json>] [--json]',
954
- summary: 'Create a split set for a dataset version.',
970
+ summary: 'Create a split set for the explicit target dataset version. --from-file accepts splits show JSON; canonical row IDs must exist in the target.',
955
971
  group: 'Datasets',
956
972
  },
957
973
  {
958
974
  path: ['datasets', 'download'],
959
- usage: 'orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--format <csv|json|jsonl>] [--out <path>]',
960
- summary: 'Download a dataset.',
975
+ usage: 'orizu datasets download [--dataset <datasetId|datasetUrl>] [--project <team/project>] [--dataset-version <id> --split-set <id> --split <name>] [--format <csv|json|jsonl>] [--out <path>]',
976
+ summary: 'Download live dataset rows, or one named split from an immutable version with canonical row IDs and existing fields in partition order. Pinned reads require all three selectors and JSON or JSONL; incomplete artifacts fail without falling back to live rows.',
961
977
  group: 'Datasets',
978
+ options: [
979
+ { name: '--dataset <datasetId|datasetUrl>', help: 'Dataset for an existing live download.' },
980
+ { name: '--project <team/project>', help: 'Project scope for live dataset selection.' },
981
+ { name: '--dataset-version <id>', help: 'Immutable version for a pinned split download; requires --split-set and --split.' },
982
+ { name: '--split-set <id>', help: 'Saved assignment set belonging to the selected version.' },
983
+ { name: '--split <name>', help: 'Exact partition name, for example validation. For names beginning with --, use --split=--validation (also --split=--json or --split=--help).' },
984
+ { name: '--format <csv|json|jsonl>', help: 'Output format. Pinned split downloads support json and jsonl only.' },
985
+ { name: '--out <path>', help: 'Write rows to a file.' },
986
+ ],
987
+ examples: ['orizu datasets download --dataset-version DATASET_VERSION_ID --split-set SPLIT_SET_ID --split validation --format jsonl --out validation.jsonl', 'orizu datasets download --dataset DATASET_ID --format jsonl --out dataset.jsonl'],
962
988
  },
963
989
  {
964
990
  path: ['datasets', 'append'],
@@ -150,6 +150,7 @@ export function deriveBootStatusUrl(agentTokenUrl: string): string {
150
150
  * secret defensively before sending. */
151
151
  const MAX_BOOT_REASON_CHARS = 800
152
152
  const TURN_STATUS_ATTEMPT_TIMEOUT_MS = 10_000
153
+ export const HOSTED_TURN_PROGRESS_REPORT_MS = 5 * 60_000
153
154
 
154
155
  function resolveTestPositiveInteger(
155
156
  processEnv: Record<string, string | undefined>,
@@ -241,7 +242,7 @@ async function postRequiredIdleReady(opts: {
241
242
  async function postTurnStatus(opts: {
242
243
  bootStatusUrl: string
243
244
  bootSecret: string
244
- status: 'turn_started' | 'turn_completed' | 'turn_failed'
245
+ status: 'turn_started' | 'turn_progress' | 'turn_completed' | 'turn_failed'
245
246
  runId: string
246
247
  reason?: string | null
247
248
  fetchImpl: BootFetch
@@ -1283,6 +1284,7 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
1283
1284
  minimumDelayMs: bearerRotationMinimumDelayMs,
1284
1285
  })
1285
1286
  const promptInterruptController = new AbortController()
1287
+ let nextProgressAt = now()
1286
1288
  const interruptPollController = new AbortController()
1287
1289
  const interruptPoll = pollHostedSessionDuringTurn({
1288
1290
  baseUrl: env.baseUrl,
@@ -1312,6 +1314,19 @@ export async function runHostedBoot(opts: RunHostedBootOptions): Promise<HostedB
1312
1314
  bearerProvider: () => readFile(bearerFileAbs).trim(),
1313
1315
  interruptSignal: promptInterruptController.signal,
1314
1316
  onDiagnostic: log,
1317
+ onProgress: async () => {
1318
+ if (now() < nextProgressAt) return
1319
+ nextProgressAt = now() + HOSTED_TURN_PROGRESS_REPORT_MS
1320
+ try {
1321
+ await postTurnStatus({
1322
+ bootStatusUrl: env.bootStatusUrl, bootSecret: env.bootSecret,
1323
+ status: 'turn_progress', runId: current.runId, fetchImpl, sleep,
1324
+ attemptTimeoutMs: turnStatusAttemptTimeoutMs,
1325
+ })
1326
+ } catch {
1327
+ log('turn progress acknowledgement unavailable')
1328
+ }
1329
+ },
1315
1330
  }
1316
1331
  )
1317
1332
  } finally {