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.
- package/dist/dataset-clone-workflow.d.ts +11 -0
- package/dist/dataset-clone-workflow.d.ts.map +1 -0
- package/dist/dataset-clone-workflow.js +100 -0
- package/dist/dataset-commands.d.ts.map +1 -1
- package/dist/dataset-commands.js +6 -29
- package/dist/dataset-transfer-upload.d.ts +2 -6
- package/dist/dataset-transfer-upload.d.ts.map +1 -1
- package/dist/dataset-transfer-upload.js +9 -2
- package/dist/dataset-upload-snapshot.d.ts.map +1 -1
- package/dist/dataset-upload-snapshot.js +7 -1
- package/dist/dataset-version-workflow.d.ts +24 -0
- package/dist/dataset-version-workflow.d.ts.map +1 -0
- package/dist/dataset-version-workflow.js +99 -0
- package/dist/feedback-cli.js +3 -3
- package/dist/hosted-harness-opencode.d.ts +1 -1
- package/dist/hosted-harness-opencode.js +7 -7
- package/dist/hosted-loop-lifecycle.d.ts +3 -2
- package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
- package/dist/hosted-loop-lifecycle.js +3 -2
- package/dist/hosted-loop.d.ts +7 -0
- package/dist/hosted-loop.d.ts.map +1 -1
- package/dist/hosted-loop.js +14 -6
- package/dist/index.js +2 -2
- package/dist/last-error-record.d.ts +4 -2
- package/dist/last-error-record.d.ts.map +1 -1
- package/dist/last-error-record.js +10 -68
- package/dist/last-error-workflow.d.ts +4 -0
- package/dist/last-error-workflow.d.ts.map +1 -0
- package/dist/last-error-workflow.js +78 -0
- package/dist/task-commands.d.ts.map +1 -1
- package/dist/task-commands.js +9 -16
- package/dist/task-report-workflow.d.ts +47 -0
- package/dist/task-report-workflow.d.ts.map +1 -0
- package/dist/task-report-workflow.js +160 -0
- package/package.json +1 -1
- package/src/dataset-clone-workflow.ts +100 -0
- package/src/dataset-commands.ts +6 -53
- package/src/dataset-transfer-upload.ts +12 -5
- package/src/dataset-upload-snapshot.ts +8 -1
- package/src/dataset-version-workflow.ts +105 -0
- package/src/feedback-cli.ts +4 -4
- package/src/hosted-harness-opencode.ts +7 -7
- package/src/hosted-loop-lifecycle.ts +3 -2
- package/src/hosted-loop.ts +14 -6
- package/src/index.ts +2 -2
- package/src/last-error-record.ts +11 -85
- package/src/last-error-workflow.ts +121 -0
- package/src/task-commands.ts +9 -43
- package/src/task-report-workflow.ts +179 -0
package/src/dataset-commands.ts
CHANGED
|
@@ -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
|
|
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
|
|
631
|
-
|
|
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
|
|
603
|
+
printJson({ dataset })
|
|
651
604
|
return
|
|
652
605
|
}
|
|
653
606
|
printLine(
|
|
654
|
-
`Cloned dataset ${sanitizeTerminalText(
|
|
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<
|
|
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 =>
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/feedback-cli.ts
CHANGED
|
@@ -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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
export const OPENCODE_PINNED_VERSION = '1.
|
|
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.
|
|
36
|
-
*
|
|
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.
|
package/src/hosted-loop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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),
|
package/src/last-error-record.ts
CHANGED
|
@@ -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
|
|
375
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
}
|