orizu 0.5.40 → 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 (49) hide show
  1. package/dist/dataset-clone-workflow.d.ts +11 -0
  2. package/dist/dataset-clone-workflow.d.ts.map +1 -0
  3. package/dist/dataset-clone-workflow.js +100 -0
  4. package/dist/dataset-commands.d.ts.map +1 -1
  5. package/dist/dataset-commands.js +6 -29
  6. package/dist/dataset-transfer-upload.d.ts +2 -6
  7. package/dist/dataset-transfer-upload.d.ts.map +1 -1
  8. package/dist/dataset-transfer-upload.js +9 -2
  9. package/dist/dataset-upload-snapshot.d.ts.map +1 -1
  10. package/dist/dataset-upload-snapshot.js +7 -1
  11. package/dist/dataset-version-workflow.d.ts +24 -0
  12. package/dist/dataset-version-workflow.d.ts.map +1 -0
  13. package/dist/dataset-version-workflow.js +99 -0
  14. package/dist/feedback-cli.js +3 -3
  15. package/dist/hosted-harness-opencode.d.ts +1 -1
  16. package/dist/hosted-harness-opencode.js +7 -7
  17. package/dist/hosted-loop-lifecycle.d.ts +3 -2
  18. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  19. package/dist/hosted-loop-lifecycle.js +3 -2
  20. package/dist/hosted-loop.d.ts +7 -0
  21. package/dist/hosted-loop.d.ts.map +1 -1
  22. package/dist/hosted-loop.js +14 -6
  23. package/dist/index.js +2 -2
  24. package/dist/last-error-record.d.ts +4 -2
  25. package/dist/last-error-record.d.ts.map +1 -1
  26. package/dist/last-error-record.js +10 -68
  27. package/dist/last-error-workflow.d.ts +4 -0
  28. package/dist/last-error-workflow.d.ts.map +1 -0
  29. package/dist/last-error-workflow.js +78 -0
  30. package/dist/task-commands.d.ts.map +1 -1
  31. package/dist/task-commands.js +9 -16
  32. package/dist/task-report-workflow.d.ts +47 -0
  33. package/dist/task-report-workflow.d.ts.map +1 -0
  34. package/dist/task-report-workflow.js +160 -0
  35. package/package.json +1 -1
  36. package/src/dataset-clone-workflow.ts +100 -0
  37. package/src/dataset-commands.ts +6 -53
  38. package/src/dataset-transfer-upload.ts +12 -5
  39. package/src/dataset-upload-snapshot.ts +8 -1
  40. package/src/dataset-version-workflow.ts +105 -0
  41. package/src/feedback-cli.ts +4 -4
  42. package/src/hosted-harness-opencode.ts +7 -7
  43. package/src/hosted-loop-lifecycle.ts +3 -2
  44. package/src/hosted-loop.ts +14 -6
  45. package/src/index.ts +2 -2
  46. package/src/last-error-record.ts +11 -85
  47. package/src/last-error-workflow.ts +121 -0
  48. package/src/task-commands.ts +9 -43
  49. package/src/task-report-workflow.ts +179 -0
@@ -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
+ }
@@ -26,20 +26,6 @@ import type { TaskStatusPayload } from './account-directory.js'
26
26
 
27
27
 
28
28
 
29
- interface TaskReportPayload {
30
- task: {
31
- id: string
32
- title?: string | null
33
- status?: string | null
34
- report: {
35
- markdown: string
36
- sourceName: string | null
37
- createdAt: string | null
38
- updatedAt?: string | null
39
- }
40
- }
41
- }
42
-
43
29
  function readTaskReportInput(): { markdown: string; sourceName: string | null } | null {
44
30
  return readMarkdownReportInput(cliArgs, 'Task')
45
31
  }
@@ -619,30 +605,14 @@ async function setTaskReport() {
619
605
  )
620
606
  }
621
607
 
622
- const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/report`, {
623
- method: 'PATCH',
624
- headers: { 'Content-Type': 'application/json' },
625
- body: JSON.stringify({
626
- reportMarkdown: report.markdown,
627
- reportSourceName: report.sourceName,
628
- }),
629
- })
630
-
631
- if (!response.ok) {
632
- throw new Error(`Failed to update task report: ${await response.text()}`)
608
+ // Loaded lazily: the workflow pulls in Effect (ORI-2408).
609
+ const workflow = await import('./task-report-workflow.js')
610
+ const outcome = await workflow.setTaskReport(taskId, report)
611
+ if (outcome.kind === 'interrupted') {
612
+ process.exitCode = outcome.exitCode
613
+ return
633
614
  }
634
-
635
- const data = await parseJsonResponse<{
636
- task: {
637
- id: string
638
- status: string
639
- report: {
640
- markdown: string
641
- sourceName: string | null
642
- createdAt: string | null
643
- }
644
- }
645
- }>(response, 'Task report update')
615
+ const data = outcome.reply
646
616
 
647
617
  if (hasJsonFlag()) {
648
618
  printJson(data)
@@ -662,12 +632,8 @@ async function getTaskReport() {
662
632
  throw new Error('Usage: orizu tasks report get --task <taskId> [--json]')
663
633
  }
664
634
 
665
- const response = await authedFetch(`/api/cli/tasks/${encodeURIComponent(taskId)}/report`)
666
- if (!response.ok) {
667
- throw new Error(`Failed to read task report: ${await response.text()}`)
668
- }
669
-
670
- const data = await parseJsonResponse<TaskReportPayload>(response, 'Task report get')
635
+ const workflow = await import('./task-report-workflow.js')
636
+ const data = await workflow.getTaskReport(taskId)
671
637
  if (hasJsonFlag()) {
672
638
  printJson(data as unknown as Record<string, unknown>)
673
639
  return
@@ -0,0 +1,179 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
4
+ import { isJsonErrorResponsePayload } from './error-response.js'
5
+ import { authedFetch } from './http.js'
6
+ import { parseJsonResponse } from './json-response.js'
7
+
8
+ // Loaded only through `await import(...)` from task-commands. One request per
9
+ // command, never retried, with the same deadline as the dataset workflows.
10
+
11
+ const mayBeSaved = (taskId: string) =>
12
+ `The task report may already be saved: check with \`orizu tasks report get --task ${taskId}\` before setting it again.`
13
+
14
+ const SetReply = Schema.Struct({
15
+ task: Schema.Struct({
16
+ id: Schema.String,
17
+ status: Schema.String,
18
+ report: Schema.Struct({
19
+ markdown: Schema.String,
20
+ sourceName: Schema.NullOr(Schema.String),
21
+ createdAt: Schema.NullOr(Schema.String),
22
+ }),
23
+ }),
24
+ })
25
+
26
+ // title, status and updatedAt stay optional, as in the payload type this replaced.
27
+ const GetReply = Schema.Struct({
28
+ task: Schema.Struct({
29
+ id: Schema.String,
30
+ title: Schema.optionalKey(Schema.NullOr(Schema.String)),
31
+ status: Schema.optionalKey(Schema.NullOr(Schema.String)),
32
+ report: Schema.Struct({
33
+ markdown: Schema.String,
34
+ sourceName: Schema.NullOr(Schema.String),
35
+ createdAt: Schema.NullOr(Schema.String),
36
+ updatedAt: Schema.optionalKey(Schema.NullOr(Schema.String)),
37
+ }),
38
+ }),
39
+ })
40
+
41
+ type TaskReportSetReply = typeof SetReply.Type
42
+ export type TaskReportGetReply = typeof GetReply.Type
43
+
44
+ // `mayBeSaved` (the #2164 rule): an error reply in the app's own `{ error }`
45
+ // shape is reported plainly, whatever its status. A lost, stalled or unreadable
46
+ // reply, a 5xx that did not come from the app (a gateway giving up while the
47
+ // app kept working), or a reply of the wrong shape means it may have been saved.
48
+ class ReportRequestFailure extends Error {
49
+ constructor(message: string, readonly mayBeSaved: boolean, cause?: unknown) { super(message, { cause }) }
50
+ }
51
+
52
+ function isAppError(response: Response, text: string): boolean {
53
+ try {
54
+ return isJsonErrorResponsePayload(response, JSON.parse(text))
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ /** One request with a deadline. Returns the reply as parsed once `accepts` agrees with its shape. */
61
+ function reportRequest(input: {
62
+ taskId: string
63
+ init: RequestInit
64
+ failurePrefix: string
65
+ label: string
66
+ accepts: (data: unknown) => boolean
67
+ deadlineMessage: string
68
+ timeoutMs: number
69
+ onSent?: () => void
70
+ }) {
71
+ return Effect.suspend(() => {
72
+ let sent = false
73
+ let timedOut = false
74
+ return Effect.tryPromise({
75
+ try: async signal => {
76
+ const request = new AbortController()
77
+ const abort = () => request.abort()
78
+ const timer = setTimeout(() => { timedOut = true; request.abort() }, input.timeoutMs)
79
+ if (signal.aborted) abort()
80
+ else signal.addEventListener('abort', abort, { once: true })
81
+ try {
82
+ const response = await authedFetch(
83
+ `/api/cli/tasks/${encodeURIComponent(input.taskId)}/report`,
84
+ { ...input.init, signal: request.signal },
85
+ () => { sent = true; input.onSent?.() }
86
+ )
87
+ if (!response.ok) {
88
+ const text = await response.text()
89
+ const fromApp = isAppError(response, text)
90
+ throw new ReportRequestFailure(`${input.failurePrefix}: ${text}`, response.status < 400 || (response.status >= 500 && !fromApp))
91
+ }
92
+ const data = await parseJsonResponse<unknown>(response, input.label)
93
+ if (!input.accepts(data)) {
94
+ throw new ReportRequestFailure(`${input.label} returned an unexpected response (status ${response.status}).`, true)
95
+ }
96
+ return data
97
+ } finally {
98
+ clearTimeout(timer)
99
+ signal.removeEventListener('abort', abort)
100
+ request.abort()
101
+ }
102
+ },
103
+ catch: cause => {
104
+ if (timedOut) return new ReportRequestFailure(input.deadlineMessage, sent, cause)
105
+ if (cause instanceof ReportRequestFailure) return cause
106
+ return new ReportRequestFailure(cause instanceof Error ? cause.message : String(cause), sent, cause)
107
+ },
108
+ })
109
+ })
110
+ }
111
+
112
+ const accepts = (schema: typeof SetReply | typeof GetReply) => (data: unknown) => Result.isSuccess(Schema.decodeUnknownResult(schema)(data))
113
+
114
+ export type TaskReportSetOutcome =
115
+ | { kind: 'saved'; reply: TaskReportSetReply }
116
+ | { kind: 'interrupted'; exitCode: 130 | 143 }
117
+
118
+ /**
119
+ * `orizu tasks report set`. Ctrl-C or SIGTERM aborts the request and, once it
120
+ * was sent, says the report may already be saved: the server finishes on its
121
+ * own. Other failures throw, adding the same line when the outcome is unknown.
122
+ */
123
+ export async function setTaskReport(taskId: string, report: { markdown: string; sourceName: string | null }): Promise<TaskReportSetOutcome> {
124
+ const timeoutMs = datasetTransferTimeoutMs()
125
+ const controller = new AbortController()
126
+ let interrupted: 130 | 143 | undefined
127
+ let sent = false
128
+ const interrupt = () => { interrupted ??= 130; controller.abort() }
129
+ const terminate = () => { interrupted ??= 143; controller.abort() }
130
+ process.on('SIGINT', interrupt)
131
+ process.on('SIGTERM', terminate)
132
+ try {
133
+ const exit = await Effect.runPromiseExit(reportRequest({
134
+ taskId,
135
+ init: {
136
+ method: 'PATCH',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ reportMarkdown: report.markdown, reportSourceName: report.sourceName }),
139
+ },
140
+ failurePrefix: 'Failed to update task report',
141
+ label: 'Task report update',
142
+ accepts: accepts(SetReply),
143
+ deadlineMessage: 'Task report save exceeded its deadline',
144
+ timeoutMs,
145
+ onSent: () => { sent = true },
146
+ }), { signal: controller.signal })
147
+ if (interrupted !== undefined) {
148
+ process.stderr.write(sent ? `Task report save interrupted. ${mayBeSaved(taskId)}\n` : 'Task report save interrupted.\n')
149
+ return { kind: 'interrupted', exitCode: interrupted }
150
+ }
151
+ if (Exit.isSuccess(exit)) return { kind: 'saved', reply: exit.value as TaskReportSetReply }
152
+ throw withMayBeSaved(Cause.squash(exit.cause), taskId)
153
+ } finally {
154
+ process.removeListener('SIGINT', interrupt)
155
+ process.removeListener('SIGTERM', terminate)
156
+ }
157
+ }
158
+
159
+ function withMayBeSaved(failure: unknown, taskId: string): unknown {
160
+ if (!(failure instanceof ReportRequestFailure)) return failure
161
+ const message = failure.mayBeSaved ? `${failure.message}\n${mayBeSaved(taskId)}` : failure.message
162
+ return new Error(message, { cause: failure.cause })
163
+ }
164
+
165
+ /** `orizu tasks report get`: one read with a deadline. Signals keep Node's default handling. */
166
+ export async function getTaskReport(taskId: string): Promise<TaskReportGetReply> {
167
+ const exit = await Effect.runPromiseExit(reportRequest({
168
+ taskId,
169
+ init: {},
170
+ failurePrefix: 'Failed to read task report',
171
+ label: 'Task report get',
172
+ accepts: accepts(GetReply),
173
+ deadlineMessage: 'Task report read exceeded its deadline',
174
+ timeoutMs: datasetTransferTimeoutMs(),
175
+ }))
176
+ if (Exit.isSuccess(exit)) return exit.value as TaskReportGetReply
177
+ const failure = Cause.squash(exit.cause)
178
+ throw failure instanceof ReportRequestFailure ? new Error(failure.message, { cause: failure.cause }) : failure
179
+ }