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
@@ -22,7 +22,6 @@ import {
22
22
  } from './account-directory.js'
23
23
  import { sanitizeTerminalText, parseJsonResponse } from './json-response.js'
24
24
  import { authedFetch } from './http.js'
25
- import { extractErrorMessage } from './error-response.js'
26
25
  import { printJson, printLine } from './cli-console-output.js'
27
26
  import { readJsonFile } from './app-commands.js'
28
27
  import { printDatasetSummaries } from './archive-artifacts-cli.js'
@@ -36,15 +35,6 @@ import type { DatasetSelection } from './account-directory.js'
36
35
 
37
36
  const MAX_README_LENGTH = 200_000
38
37
 
39
- interface DatasetVersionResponse {
40
- datasetVersion: {
41
- id: string
42
- rowCount: number
43
- artifactFormat?: string
44
- artifactStoragePath?: string
45
- }
46
- }
47
-
48
38
  function readReadmeFile(readmeFile: string): string {
49
39
  try {
50
40
  return readFileSync(expandHomePath(readmeFile), 'utf8')
@@ -127,28 +117,6 @@ async function resolveDatasetIdForProject(datasetRef: string, projectArg: string
127
117
  return matches[0].id
128
118
  }
129
119
 
130
- async function requestDatasetVersionSnapshot(
131
- datasetId: string,
132
- options: { versionLabel?: string | null; readmeMarkdown?: string | null } = {}
133
- ): Promise<DatasetVersionResponse> {
134
- const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/versions`, {
135
- method: 'POST',
136
- headers: { 'Content-Type': 'application/json' },
137
- body: JSON.stringify({
138
- ...(options.versionLabel ? { versionLabel: options.versionLabel } : {}),
139
- ...(options.readmeMarkdown !== undefined && options.readmeMarkdown !== null
140
- ? { readmeMarkdown: options.readmeMarkdown }
141
- : {}),
142
- }),
143
- })
144
-
145
- if (!response.ok) {
146
- throw new Error(`Failed to create dataset version: ${await extractErrorMessage(response)}`)
147
- }
148
-
149
- return parseJsonResponse<DatasetVersionResponse>(response, 'Dataset version create')
150
- }
151
-
152
120
  async function createDatasetVersion() {
153
121
  const datasetId = getPositionalArg(3) || getArg('--dataset')
154
122
  const versionLabel = getArg('--label') || getArg('--version-label') || null
@@ -161,7 +129,8 @@ async function createDatasetVersion() {
161
129
 
162
130
  const readmeMarkdown = readReadmeMarkdownFromArgs(usage)
163
131
  const resolvedDatasetId = await resolveDatasetIdForProject(datasetId, project)
164
- const data = await requestDatasetVersionSnapshot(resolvedDatasetId, {
132
+ const { createDatasetVersionSnapshot } = await import('./dataset-version-workflow.js')
133
+ const data = await createDatasetVersionSnapshot(resolvedDatasetId, {
165
134
  versionLabel,
166
135
  readmeMarkdown,
167
136
  })
@@ -627,31 +596,15 @@ async function cloneDataset() {
627
596
  datasetId = selected.datasetId
628
597
  }
629
598
 
630
- const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/clone`, {
631
- method: 'POST',
632
- headers: { 'Content-Type': 'application/json' },
633
- body: JSON.stringify(name ? { name } : {}),
634
- })
635
-
636
- if (!response.ok) {
637
- throw new Error(`Clone failed: ${await response.text()}`)
638
- }
639
-
640
- const data = await parseJsonResponse<{
641
- dataset: {
642
- id: string
643
- name: string
644
- rowCount: number
645
- parentDatasetId: string
646
- }
647
- }>(response, 'Dataset clone')
599
+ const { cloneDataset: requestClone } = await import('./dataset-clone-workflow.js')
600
+ const dataset = await requestClone(datasetId, name ?? null)
648
601
 
649
602
  if (hasJsonFlag()) {
650
- printJson({ dataset: data.dataset })
603
+ printJson({ dataset })
651
604
  return
652
605
  }
653
606
  printLine(
654
- `Cloned dataset ${sanitizeTerminalText(data.dataset.parentDatasetId)} -> ${sanitizeTerminalText(data.dataset.name)} (${sanitizeTerminalText(data.dataset.id)}). Row count: ${data.dataset.rowCount}`
607
+ `Cloned dataset ${sanitizeTerminalText(dataset.parentDatasetId)} -> ${sanitizeTerminalText(dataset.name)} (${sanitizeTerminalText(dataset.id)}). Row count: ${dataset.rowCount}`
655
608
  )
656
609
  }
657
610
 
@@ -2,6 +2,7 @@ import { Context, Effect, Layer, Result } from 'effect'
2
2
 
3
3
  import { ensureDatasetUploadSnapshot } from './dataset-upload-snapshot.js'
4
4
  import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
5
+ import { datasetVersionSnapshot, type DatasetVersionFailure, type DatasetVersionResponse } from './dataset-version-workflow.js'
5
6
  import { extractErrorMessage } from './error-response.js'
6
7
  import { authedFetch } from './http.js'
7
8
  import { parseJsonResponse, sanitizeTerminalText } from './json-response.js'
@@ -12,7 +13,6 @@ export interface DatasetUploadResponse {
12
13
  readmeVersion?: { id: string; version_num: number; created_at: string } | null
13
14
  }
14
15
 
15
- interface DatasetSnapshot { datasetVersion: { id: string } }
16
16
  interface DatasetAppendResponse { dataset: { id: string; name: string; rowCount: number }; appendedCount: number }
17
17
  type UploadRows = Array<Record<string, unknown>>
18
18
  type UploadSource = { kind: 'jsonl'; file: string } | { kind: 'rows'; rows: UploadRows; sourceType: 'csv' | 'json' | 'jsonl' }
@@ -41,10 +41,17 @@ class DatasetInputFailure extends Error {
41
41
  interface DatasetUploadApi {
42
42
  create: (rows: UploadRows, sourceType: string) => Effect.Effect<DatasetUploadResponse, DatasetWriteFailure>
43
43
  append: (id: string, rows: UploadRows) => Effect.Effect<DatasetAppendResponse, DatasetWriteFailure>
44
- snapshot: (id: string) => Effect.Effect<DatasetSnapshot, DatasetWriteFailure>
44
+ snapshot: (id: string) => Effect.Effect<DatasetVersionResponse, DatasetVersionFailure>
45
45
  }
46
46
  const DatasetUploadApi = Context.Service<DatasetUploadApi>('orizu/DatasetUploadApi')
47
47
 
48
+ // authedFetch raises this code only when it sent nothing, or after the server
49
+ // answered 401 (it writes nothing) and the stored credentials changed, so it
50
+ // refused to replay. Either way nothing was written (ORI-2431).
51
+ function isAuthContextChange(cause: unknown) {
52
+ return cause instanceof Error && cause.message.startsWith('ORIZU_AUTH_CONTEXT_CHANGED:')
53
+ }
54
+
48
55
  // Only the existing authentication client's rejected-token refresh may replay a
49
56
  // request. No transient/transport retries belong around these non-idempotent writes.
50
57
  function post<T>(path: string, body: unknown, context: string, prefix: string, timeoutMs: number): Effect.Effect<T, DatasetWriteFailure> {
@@ -62,7 +69,7 @@ function post<T>(path: string, body: unknown, context: string, prefix: string, t
62
69
  return await parseJsonResponse<T>(response, context)
63
70
  },
64
71
  catch: cause => cause instanceof DatasetWriteFailure ? cause : new DatasetWriteFailure(
65
- cause instanceof Error ? cause.message : String(cause), requestStarted ? 'unknown' : 'rejected', { cause },
72
+ cause instanceof Error ? cause.message : String(cause), requestStarted && !isAuthContextChange(cause) ? 'unknown' : 'rejected', { cause },
66
73
  ),
67
74
  }).pipe(Effect.timeoutOrElse({
68
75
  duration: timeoutMs,
@@ -79,7 +86,7 @@ function liveUploadApi(options: DatasetUploadOptions) {
79
86
  ...(options.readmeMarkdown !== null ? { readmeMarkdown: options.readmeMarkdown } : {}),
80
87
  }, 'Dataset upload', 'Upload failed', timeoutMs),
81
88
  append: (id, rows) => post<DatasetAppendResponse>(`/api/cli/datasets/${encodeURIComponent(id)}/rows`, { rows }, 'Dataset append', 'Append failed', timeoutMs),
82
- snapshot: id => post<DatasetSnapshot>(`/api/cli/datasets/${encodeURIComponent(id)}/versions`, {}, 'Dataset version create', 'Failed to create dataset version', timeoutMs),
89
+ snapshot: id => datasetVersionSnapshot(id, {}, timeoutMs),
83
90
  })
84
91
  }
85
92
 
@@ -140,6 +147,6 @@ function uploadProgram(options: DatasetUploadOptions) {
140
147
  })
141
148
  }
142
149
 
143
- export function transferDatasetUpload(options: DatasetUploadOptions): Promise<{ data: DatasetUploadResponse; version: DatasetSnapshot }> {
150
+ export function transferDatasetUpload(options: DatasetUploadOptions): Promise<{ data: DatasetUploadResponse; version: DatasetVersionResponse }> {
144
151
  return expectedPromise(uploadProgram(options).pipe(Effect.scoped, Effect.provide(liveUploadApi(options))))
145
152
  }
@@ -1,3 +1,5 @@
1
+ import { DatasetVersionFailure, VERSION_MAY_BE_CREATED } from './dataset-version-workflow.js'
2
+
1
3
  interface UploadedDatasetIdentity {
2
4
  id: string
3
5
  name: string
@@ -20,6 +22,8 @@ export async function ensureDatasetUploadSnapshot<T extends DatasetVersionSnapsh
20
22
  } catch (error) {
21
23
  const detail = error instanceof Error ? error.message : String(error)
22
24
  const retryCommand = `orizu datasets versions create --dataset ${dataset.id}`
25
+ // After an unclear outcome the version may exist; check before retrying.
26
+ const mayBeCreated = error instanceof DatasetVersionFailure && error.mayBeCreated
23
27
 
24
28
  if (jsonOutput) {
25
29
  throw new Error(JSON.stringify({
@@ -29,13 +33,16 @@ export async function ensureDatasetUploadSnapshot<T extends DatasetVersionSnapsh
29
33
  dataset_id: dataset.id,
30
34
  dataset_version_id: null,
31
35
  retry_command: retryCommand,
36
+ ...(mayBeCreated ? { may_be_created: true } : {}),
32
37
  }))
33
38
  }
34
39
 
35
40
  throw new Error(
36
41
  `Dataset upload completed but dataset version creation failed: ${detail}\n` +
37
42
  `Dataset ${dataset.name} (${dataset.id}) was created with ${dataset.rowCount} rows. ` +
38
- `Retry only the snapshot step with: ${retryCommand}`
43
+ (mayBeCreated
44
+ ? `${VERSION_MAY_BE_CREATED} If it is not there, create it with: ${retryCommand}`
45
+ : `Retry only the snapshot step with: ${retryCommand}`)
39
46
  )
40
47
  }
41
48
  }
@@ -0,0 +1,105 @@
1
+ import { Cause, Effect, Exit, Result, Schema } from 'effect'
2
+
3
+ import { datasetTransferTimeoutMs } from './dataset-transfer-policy.js'
4
+ import { extractErrorMessage, 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(...)`, like the README and delete workflows.
9
+
10
+ export const VERSION_MAY_BE_CREATED = "The version may already be created: check the dataset's versions in the web app before creating it again."
11
+ // What the server says when it cannot tell whether the version was saved.
12
+ const SERVER_MAY_BE_CREATED = 'Dataset version may already be created'
13
+
14
+ const CreateReply = Schema.Struct({
15
+ datasetVersion: Schema.Struct({
16
+ id: Schema.NonEmptyString,
17
+ rowCount: Schema.Number,
18
+ artifactFormat: Schema.optional(Schema.String),
19
+ artifactStoragePath: Schema.optional(Schema.String),
20
+ }),
21
+ })
22
+
23
+ export type DatasetVersionResponse = typeof CreateReply.Type
24
+
25
+ export interface DatasetVersionRequest {
26
+ versionLabel?: string | null
27
+ readmeMarkdown?: string | null
28
+ }
29
+
30
+ // The version may exist when the server says so, when the reply was lost or
31
+ // unreadable after the request was sent, or when a 5xx did not come from the
32
+ // app (a gateway giving up while the app kept working). Any other error reply
33
+ // is a clear refusal.
34
+ export class DatasetVersionFailure extends Error {
35
+ readonly _tag = 'DatasetVersionFailure'
36
+ constructor(message: string, readonly mayBeCreated: boolean) { super(message) }
37
+ }
38
+
39
+ async function appErrorOf(response: Response): Promise<string | null> {
40
+ try {
41
+ const payload: unknown = await response.json()
42
+ return isJsonErrorResponsePayload(response, payload) ? payload.error : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ // One request, never retried: a retry after an unknown outcome could add a
49
+ // second version.
50
+ export function datasetVersionSnapshot(datasetId: string, options: DatasetVersionRequest, timeoutMs: number): Effect.Effect<DatasetVersionResponse, DatasetVersionFailure> {
51
+ return Effect.suspend(() => {
52
+ let sent = false
53
+ let timedOut = false
54
+ return Effect.tryPromise({
55
+ try: async signal => {
56
+ const request = new AbortController()
57
+ const abort = () => request.abort()
58
+ const timer = setTimeout(() => { timedOut = true; request.abort() }, timeoutMs)
59
+ if (signal.aborted) abort()
60
+ else signal.addEventListener('abort', abort, { once: true })
61
+ try {
62
+ const response = await authedFetch(`/api/cli/datasets/${encodeURIComponent(datasetId)}/versions`, {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({
66
+ ...(options.versionLabel ? { versionLabel: options.versionLabel } : {}),
67
+ ...(options.readmeMarkdown !== undefined && options.readmeMarkdown !== null
68
+ ? { readmeMarkdown: options.readmeMarkdown }
69
+ : {}),
70
+ }),
71
+ signal: request.signal,
72
+ }, () => { sent = true })
73
+ if (!response.ok) {
74
+ const serverError = await appErrorOf(response.clone())
75
+ const message = `Failed to create dataset version: ${await extractErrorMessage(response)}`
76
+ throw new DatasetVersionFailure(message, response.status < 400 || serverError === SERVER_MAY_BE_CREATED || (response.status >= 500 && serverError === null))
77
+ }
78
+ const data = await parseJsonResponse<unknown>(response, 'Dataset version create')
79
+ const decoded = Schema.decodeUnknownResult(CreateReply)(data)
80
+ if (Result.isFailure(decoded)) {
81
+ throw new DatasetVersionFailure(`Dataset version create returned an unexpected response (status ${response.status}).`, true)
82
+ }
83
+ return decoded.success
84
+ } finally {
85
+ clearTimeout(timer)
86
+ signal.removeEventListener('abort', abort)
87
+ request.abort()
88
+ }
89
+ },
90
+ catch: cause => {
91
+ if (timedOut) return new DatasetVersionFailure('DATASET_TRANSFER_TIMEOUT: Dataset version create exceeded its deadline', sent)
92
+ if (cause instanceof DatasetVersionFailure) return cause
93
+ return new DatasetVersionFailure(cause instanceof Error ? cause.message : String(cause), sent)
94
+ },
95
+ })
96
+ })
97
+ }
98
+
99
+ export async function createDatasetVersionSnapshot(datasetId: string, options: DatasetVersionRequest): Promise<DatasetVersionResponse> {
100
+ const exit = await Effect.runPromiseExit(datasetVersionSnapshot(datasetId, options, datasetTransferTimeoutMs()).pipe(
101
+ Effect.mapError(failure => new Error(failure.mayBeCreated ? `${failure.message}\n${VERSION_MAY_BE_CREATED}` : failure.message)),
102
+ ))
103
+ if (Exit.isSuccess(exit)) return exit.value
104
+ throw Cause.squash(exit.cause)
105
+ }
@@ -419,15 +419,15 @@ function resolveProjectContext(
419
419
  return { teamSlug: workspaceTeam, projectSlug: null }
420
420
  }
421
421
 
422
- function lastErrorForContext(
422
+ async function lastErrorForContext(
423
423
  configDirectory: string,
424
424
  serverBaseUrl: string,
425
425
  teamSlug: string | null,
426
426
  now: number,
427
427
  printErr: (message: string) => void,
428
428
  scrubNotice: (message: string) => string
429
- ): LastErrorRecord | null {
430
- const record = readLastErrorRecord(configDirectory)
429
+ ): Promise<LastErrorRecord | null> {
430
+ const record = await readLastErrorRecord(configDirectory)
431
431
  if (!record || !isLastErrorRecordShape(record)) return null
432
432
  if (teamSlug === null) {
433
433
  printErr('last error not attached: no team context')
@@ -633,7 +633,7 @@ export async function feedbackCommand(args: string[], io: FeedbackCliIo): Promis
633
633
  const values = { ...fromFile, ...parsed.values }
634
634
  const lastError = parsed.suppressLastError
635
635
  ? null
636
- : lastErrorForContext(
636
+ : await lastErrorForContext(
637
637
  configDirectory,
638
638
  baseUrl,
639
639
  projectContext.teamSlug,
@@ -66,13 +66,13 @@ import type {
66
66
  } from './hosted-harness.js'
67
67
 
68
68
  // OpenCode is HARD-PINNED to this version everywhere it is installed/launched.
69
- // AUDIT RISK #1 (SSE fragility): the entire event model rides OpenCode's
70
- // undocumented `/event` SSE part schema (`message.part.updated`, `session.idle`,
71
- // `session.error`, `session.updated`, ...). Versions newer than 1.14.41 broke
72
- // that stream upstream, so the pin is load-bearing and must be re-verified
73
- // before any bump. The AgentHarness seam is the mitigation of last resort: swap
74
- // to a Claude-Agent-SDK loop (ALI-929) rather than chase OpenCode's SSE.
75
- export const OPENCODE_PINNED_VERSION = '1.14.41'
69
+ // The event model rides OpenCode's undocumented `/event` SSE part schema
70
+ // (`message.part.updated`, `session.idle`, `session.error`, `session.updated`,
71
+ // ...), so every bump must pass test/cli/hosted-opencode-contract-real.test.ts
72
+ // against the real binary (the path-gated `opencode-real` CI job runs it when
73
+ // this pin changes). 1.14.41 → 1.18.35 was measured and checked that way
74
+ // (ORI-2523). The AgentHarness seam remains the mitigation of last resort.
75
+ export const OPENCODE_PINNED_VERSION = '1.18.35'
76
76
 
77
77
  const DEFAULT_OPENCODE_PORT = 4096
78
78
  const DEFAULT_SSE_INACTIVITY_MS = 120_000
@@ -32,8 +32,9 @@ import type { HostedSessionOrigin } from './hosted-runtime-assets.js'
32
32
  * re-inline it.
33
33
  *
34
34
  * PIN CONSTRAINT: the hosted OpenCode runtime is HARD-PINNED to
35
- * `opencode-ai@1.14.41` (`OPENCODE_PINNED_VERSION`, SSE-fragile — do not bump)
36
- * whose bundled catalog predates this model. Images bake a validated models.dev
35
+ * `OPENCODE_PINNED_VERSION` (opencode-ai@1.18.35, whose bundled catalog
36
+ * includes this model; bumps are checked by the real-binary contract test,
37
+ * ORI-2523). Images also bake a validated models.dev
37
38
  * list into the runtime user's cache. Orizu refreshes that cache before spawning
38
39
  * OpenCode, preserving baked models on failure; the native unchecked writer is
39
40
  * disabled (ORI-2513). Pre-prompt validation still checks the running server.
@@ -328,14 +328,22 @@ function detectLoopPrebaked(flag: boolean | undefined, markerPath: string): bool
328
328
  }
329
329
 
330
330
  /** Best-effort global install of the pinned opencode. Non-fatal: a failure is
331
- * recorded and the run finishes cleanly when the subsequent connect fails. */
332
- function installOpenCodePinned(version: string): InstallResult {
331
+ * recorded and the run finishes cleanly when the subsequent connect fails.
332
+ * From 1.18 opencode's postinstall script writes the `opencode` command, and
333
+ * npm 12 blocks it unless allowed, leaving a placeholder that exits 1; so npm
334
+ * allows that one script and the result must print the pinned version
335
+ * (ORI-2523). `env` exists for tests. */
336
+ export function installOpenCodePinned(version: string, env?: NodeJS.ProcessEnv): InstallResult {
333
337
  const command =
334
- `bun add -g opencode-ai@${version} >/dev/null 2>&1 || ` +
335
- `npm install -g opencode-ai@${version} >/dev/null 2>&1`
336
- const res = spawnSync('bash', ['-c', command], { encoding: 'utf8' })
338
+ `{ bun add -g opencode-ai@${version} >/dev/null 2>&1 || ` +
339
+ `npm install -g --allow-scripts=opencode-ai opencode-ai@${version} >/dev/null 2>&1; } && ` +
340
+ `v="$(opencode --version 2>/dev/null)" && [ "$v" = '${version}' ]`
341
+ const res = spawnSync('bash', ['-c', command], { encoding: 'utf8', ...(env ? { env } : {}) })
337
342
  const ok = (res.status ?? 1) === 0
338
- return { ok, detail: ok ? `installed opencode-ai@${version}` : `install exit ${res.status ?? 'unknown'}` }
343
+ return {
344
+ ok,
345
+ detail: ok ? `installed opencode-ai@${version}` : `install exit ${res.status ?? 'unknown'}, or opencode --version is not ${version}`,
346
+ }
339
347
  }
340
348
 
341
349
  /** Node child_process spawner for `opencode serve` (the sandbox runtime is
package/src/index.ts CHANGED
@@ -2262,10 +2262,10 @@ function isCliEntrypoint(): boolean {
2262
2262
  if (isCliEntrypoint()) {
2263
2263
  const rawArgs = process.argv.slice(2)
2264
2264
  withPassiveUpdateNotice(rawArgs, getCliVersion, printError, args =>
2265
- main(args).catch(error => {
2265
+ main(args).catch(async error => {
2266
2266
  const message = error instanceof Error ? error.message : 'Unknown error'
2267
2267
  // The reporter email is unknown on the failure path, so the record masks every address.
2268
- writeLastErrorRecord({
2268
+ await writeLastErrorRecord({
2269
2269
  argv: args,
2270
2270
  message,
2271
2271
  code: extractLastErrorCode(error),
@@ -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
  }