orizu 0.5.47 → 0.5.49
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/app-publish-workspace.d.ts +6 -0
- package/dist/app-publish-workspace.d.ts.map +1 -1
- package/dist/app-publish-workspace.js +54 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +12 -1
- package/dist/hosted-boot.d.ts +7 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +31 -2
- package/dist/hosted-optimization.d.ts.map +1 -1
- package/dist/hosted-optimization.js +40 -21
- package/dist/hosted-runtime-assets.d.ts +9 -0
- package/dist/hosted-runtime-assets.d.ts.map +1 -1
- package/dist/hosted-runtime-assets.js +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +83 -68
- package/dist/runner-folders.d.ts +61 -0
- package/dist/runner-folders.d.ts.map +1 -0
- package/dist/runner-folders.js +222 -0
- package/dist/runner-version-materialization.d.ts +1 -1
- package/dist/runner-version-materialization.d.ts.map +1 -1
- package/dist/runner-version-materialization.js +20 -38
- package/dist/session-publish.d.ts +8 -0
- package/dist/session-publish.d.ts.map +1 -0
- package/dist/session-publish.js +118 -0
- package/dist/skilled-proposer-launch.d.ts +11 -1
- package/dist/skilled-proposer-launch.d.ts.map +1 -1
- package/dist/skilled-proposer-launch.js +35 -4
- package/dist/workbench-cli.d.ts.map +1 -1
- package/dist/workbench-cli.js +14 -1
- package/package.json +1 -1
- package/scripts/build-cli-dist.mjs +46 -8
- package/scripts/vendor-orizu-skill.mjs +52 -7
- package/src/app-publish-workspace.ts +57 -1
- package/src/help.ts +13 -1
- package/src/hosted-boot.ts +40 -2
- package/src/hosted-optimization.ts +49 -31
- package/src/hosted-runtime-assets.ts +10 -9
- package/src/index.ts +79 -87
- package/src/runner-folders.ts +223 -0
- package/src/runner-version-materialization.ts +21 -34
- package/src/session-publish.ts +127 -0
- package/src/skilled-proposer-launch.ts +41 -3
- package/src/workbench-cli.ts +14 -1
- package/vendor/skills/orizu/references/authority-map.md +1 -1
- package/vendor/skills/orizu/references/building-apps.md +1 -1
- package/vendor/skills/orizu/references/cli-reference.md +16 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { Cause, Effect, Exit, Fiber, Scope } from 'effect'
|
|
6
|
+
|
|
7
|
+
import { ARTIFACT_MAX_BYTES } from './artifact-pull.js'
|
|
8
|
+
import { authedFetch } from './http.js'
|
|
9
|
+
import { unzipRunnerArchive } from './runner-version-materialization.js'
|
|
10
|
+
|
|
11
|
+
// ORI-2429: Effect owns the lifetime of the runner copies the CLI writes to
|
|
12
|
+
// the temp folder (downloaded runner versions and verified snapshots). They
|
|
13
|
+
// hold the customer's runner code, so the scope removes them however the run
|
|
14
|
+
// ends: success, failure or a stop signal. Loaded with a dynamic import, so
|
|
15
|
+
// commands that never copy a runner do not load Effect.
|
|
16
|
+
|
|
17
|
+
export type RemovalGroup = 'downloads' | 'snapshots' | 'other'
|
|
18
|
+
|
|
19
|
+
/** Removal failures seen while a scope closes; Effect finalizers cannot fail. */
|
|
20
|
+
export class RemovalLedger {
|
|
21
|
+
readonly failures: { group: RemovalGroup; error: unknown }[] = []
|
|
22
|
+
has(group?: RemovalGroup): boolean {
|
|
23
|
+
return this.failures.some(failure => !group || failure.group === group)
|
|
24
|
+
}
|
|
25
|
+
errors(): unknown[] {
|
|
26
|
+
return this.failures.map(failure => failure.error)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remove with `remove` when the surrounding scope closes, recording a failure. */
|
|
31
|
+
export function removeOnClose(ledger: RemovalLedger, group: RemovalGroup, remove: () => void) {
|
|
32
|
+
return Effect.addFinalizer(() => Effect.sync(() => {
|
|
33
|
+
try { remove() } catch (error) { ledger.failures.push({ group, error }) }
|
|
34
|
+
}))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function request(path: string, signal: AbortSignal) {
|
|
38
|
+
return Effect.tryPromise({
|
|
39
|
+
try: requestSignal => authedFetch(path, { signal: AbortSignal.any([requestSignal, signal]) }),
|
|
40
|
+
catch: error => error,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function text(response: Response) {
|
|
45
|
+
return Effect.tryPromise({ try: () => response.text(), catch: error => error })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Download a registered runner version into a private temp folder that the
|
|
50
|
+
* caller's scope removes. A stop mid-download aborts the transfer and still
|
|
51
|
+
* removes the folder. Messages match the previous Promise implementation.
|
|
52
|
+
*/
|
|
53
|
+
export function downloadedRunner(runnerVersionId: string, ledger: RemovalLedger, verifyRegisteredDigest = true) {
|
|
54
|
+
return Effect.gen(function* () {
|
|
55
|
+
// Aborting on close also ends a body still streaming when a stop arrives.
|
|
56
|
+
const controller = yield* Effect.acquireRelease(
|
|
57
|
+
Effect.sync(() => new AbortController()),
|
|
58
|
+
controller => Effect.sync(() => controller.abort()),
|
|
59
|
+
)
|
|
60
|
+
let contentSha256: string | undefined
|
|
61
|
+
if (verifyRegisteredDigest) {
|
|
62
|
+
const detailResponse = yield* request(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}`, controller.signal)
|
|
63
|
+
if (!detailResponse.ok) return yield* Effect.fail(new Error(`Failed to resolve runner version: ${yield* text(detailResponse)}`))
|
|
64
|
+
const detail = (yield* Effect.tryPromise({ try: () => detailResponse.json(), catch: error => error })) as Record<string, unknown>
|
|
65
|
+
if (detail.runnerVersionId !== runnerVersionId || detail.status !== 'sealed'
|
|
66
|
+
|| typeof detail.contentSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(detail.contentSha256)) {
|
|
67
|
+
return yield* Effect.fail(new Error('Runner version detail is invalid'))
|
|
68
|
+
}
|
|
69
|
+
contentSha256 = detail.contentSha256
|
|
70
|
+
}
|
|
71
|
+
const response = yield* request(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}/download`, controller.signal)
|
|
72
|
+
if (!response.ok) return yield* Effect.fail(new Error(`Failed to download runner version: ${yield* text(response)}`))
|
|
73
|
+
// The previous implementation removed the folder as soon as this runner
|
|
74
|
+
// failed, naming the runner when that removal failed too. Keep both.
|
|
75
|
+
let removed = false
|
|
76
|
+
let tempDir = ''
|
|
77
|
+
const remove = () => { if (!removed) { removed = true; rmSync(tempDir, { recursive: true, force: true }) } }
|
|
78
|
+
// Created and registered for removal in one uninterruptible step.
|
|
79
|
+
yield* Effect.acquireRelease(
|
|
80
|
+
Effect.sync(() => { tempDir = mkdtempSync(join(tmpdir(), 'orizu-runner-version-')) }),
|
|
81
|
+
() => Effect.sync(() => {
|
|
82
|
+
try { remove() } catch (error) { ledger.failures.push({ group: 'downloads', error }) }
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
return yield* Effect.gen(function* () {
|
|
86
|
+
const zipBytes = new Uint8Array(yield* Effect.tryPromise({ try: () => response.arrayBuffer(), catch: error => error }))
|
|
87
|
+
if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) return yield* Effect.fail(new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`))
|
|
88
|
+
if (contentSha256 && createHash('sha256').update(zipBytes).digest('hex') !== contentSha256) {
|
|
89
|
+
return yield* Effect.fail(new Error('Runner artifact digest mismatch'))
|
|
90
|
+
}
|
|
91
|
+
const zipPath = join(tempDir, 'runner.zip'), runnerDir = join(tempDir, 'runner')
|
|
92
|
+
yield* Effect.try({ try: () => { writeFileSync(zipPath, zipBytes); unzipRunnerArchive(zipPath, runnerDir) }, catch: error => error })
|
|
93
|
+
return { runnerDir, remove }
|
|
94
|
+
}).pipe(Effect.catch(error => Effect.suspend(() => {
|
|
95
|
+
try { remove() } catch (cleanupError) {
|
|
96
|
+
return Effect.fail(new AggregateError([error, cleanupError], `Runner ${runnerVersionId} materialization failed and cleanup also failed`))
|
|
97
|
+
}
|
|
98
|
+
return Effect.fail(error)
|
|
99
|
+
})))
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A Promise-returning resource (acquired whole) whose cleanup the scope runs. */
|
|
104
|
+
export function heldUntilClose<A extends { cleanup: () => void }>(ledger: RemovalLedger, group: RemovalGroup, acquire: () => Promise<A>) {
|
|
105
|
+
// Acquiring stays uninterruptible: a stop waits for it to finish, so a
|
|
106
|
+
// half-created snapshot is never orphaned; the scope then removes it.
|
|
107
|
+
return Effect.acquireRelease(
|
|
108
|
+
Effect.tryPromise({ try: acquire, catch: error => error }),
|
|
109
|
+
resource => Effect.sync(() => {
|
|
110
|
+
try { resource.cleanup() } catch (error) { ledger.failures.push({ group, error }) }
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const
|
|
116
|
+
// Matches the launcher's stop cap (ORI-2402): a stop never waits longer.
|
|
117
|
+
const STOP_CLEANUP_WAIT_MS = 15_000
|
|
118
|
+
|
|
119
|
+
export interface StopControl {
|
|
120
|
+
/** Hand stop signals to the optimizer launcher; pass this as its beforeSignalExit. */
|
|
121
|
+
readonly beforeSignalExit: () => Promise<void> | void
|
|
122
|
+
readonly launcherOwnsStop: () => void
|
|
123
|
+
readonly launcherDone: () => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run `program` in a scope that removes the runner copies however it ends.
|
|
128
|
+
* Before the optimizer starts, a stop signal interrupts the program (the scope
|
|
129
|
+
* closes as it unwinds) and the CLI then ends by that signal. While the
|
|
130
|
+
* launcher runs the optimizer, the launcher owns the signal and calls
|
|
131
|
+
* `beforeSignalExit`, which closes the scope synchronously: the removal then
|
|
132
|
+
* finishes even when the optimizer used up the whole stop cap.
|
|
133
|
+
*/
|
|
134
|
+
export async function runStoppable<A>(program: (stop: StopControl) => Effect.Effect<A, unknown, Scope.Scope>): Promise<A> {
|
|
135
|
+
const { scope, close } = openScope()
|
|
136
|
+
let launcherOwned = false
|
|
137
|
+
let stopping = false
|
|
138
|
+
// eslint-disable-next-line prefer-const -- assigned after the handlers that read it
|
|
139
|
+
let fiber: Fiber.Fiber<A, unknown>
|
|
140
|
+
const interrupt = () => Effect.runPromise(Fiber.interrupt(fiber))
|
|
141
|
+
const removeHandlers = () => { for (const entry of handlers) process.off(entry.signal, entry.handle) }
|
|
142
|
+
const handlers = STOP_SIGNALS.map(signal => ({
|
|
143
|
+
signal,
|
|
144
|
+
handle: () => {
|
|
145
|
+
if (launcherOwned || stopping) return
|
|
146
|
+
stopping = true
|
|
147
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
148
|
+
const expired = new Promise<void>(resolve => { timer = setTimeout(resolve, STOP_CLEANUP_WAIT_MS) })
|
|
149
|
+
void Promise.race([interrupt(), expired]).finally(() => {
|
|
150
|
+
clearTimeout(timer)
|
|
151
|
+
removeHandlers()
|
|
152
|
+
process.kill(process.pid, signal)
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
}))
|
|
156
|
+
const control: StopControl = {
|
|
157
|
+
beforeSignalExit: () => {
|
|
158
|
+
stopping = true
|
|
159
|
+
// The launcher re-sends the signal next; none of ours may catch it.
|
|
160
|
+
removeHandlers()
|
|
161
|
+
close()
|
|
162
|
+
},
|
|
163
|
+
launcherOwnsStop: () => { launcherOwned = true },
|
|
164
|
+
launcherDone: () => { launcherOwned = false },
|
|
165
|
+
}
|
|
166
|
+
for (const entry of handlers) process.on(entry.signal, entry.handle)
|
|
167
|
+
fiber = Effect.runFork(Scope.provide(scope)(program(control)).pipe(Effect.ensuring(Effect.sync(close))))
|
|
168
|
+
try {
|
|
169
|
+
const exit = await Effect.runPromise(Effect.exit(Fiber.join(fiber)))
|
|
170
|
+
// A stop ends the process by its signal; nothing more to report here.
|
|
171
|
+
if (stopping) return await new Promise<never>(() => {})
|
|
172
|
+
if (Exit.isSuccess(exit)) return exit.value
|
|
173
|
+
throw Cause.squash(exit.cause)
|
|
174
|
+
} finally {
|
|
175
|
+
removeHandlers()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* For a workflow that stays a Promise (hosted optimization): a scope that a
|
|
181
|
+
* stop signal closes before the CLI ends by that signal. Before the optimizer
|
|
182
|
+
* starts our handlers do it; while the launcher runs it, the launcher calls
|
|
183
|
+
* `stop.beforeSignalExit`. Closing aborts an in-flight download and removes
|
|
184
|
+
* every registered file, folder and server; the finalizers are synchronous.
|
|
185
|
+
*/
|
|
186
|
+
export function stopClosesScope() {
|
|
187
|
+
const { scope, close } = openScope()
|
|
188
|
+
let launcherOwned = false
|
|
189
|
+
let stopping = false
|
|
190
|
+
const removeHandlers = () => { for (const entry of handlers) process.off(entry.signal, entry.handle) }
|
|
191
|
+
const handlers = STOP_SIGNALS.map(signal => ({
|
|
192
|
+
signal,
|
|
193
|
+
handle: () => {
|
|
194
|
+
if (launcherOwned || stopping) return
|
|
195
|
+
stopping = true
|
|
196
|
+
close()
|
|
197
|
+
removeHandlers()
|
|
198
|
+
process.kill(process.pid, signal)
|
|
199
|
+
},
|
|
200
|
+
}))
|
|
201
|
+
// Installed by the caller inside its try, so a setup throw cannot leave them behind.
|
|
202
|
+
const listen = () => { for (const entry of handlers) process.on(entry.signal, entry.handle) }
|
|
203
|
+
const stop: StopControl = {
|
|
204
|
+
beforeSignalExit: () => { stopping = true; removeHandlers(); close() },
|
|
205
|
+
launcherOwnsStop: () => { launcherOwned = true },
|
|
206
|
+
launcherDone: () => { launcherOwned = false },
|
|
207
|
+
}
|
|
208
|
+
return { scope, close, stop, listen, removeHandlers }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Open a scope for an adapter that must hand back a synchronous cleanup. */
|
|
212
|
+
export function openScope() {
|
|
213
|
+
const scope = Scope.makeUnsafe()
|
|
214
|
+
let closed = false
|
|
215
|
+
return {
|
|
216
|
+
scope,
|
|
217
|
+
close: () => {
|
|
218
|
+
if (closed) return
|
|
219
|
+
closed = true
|
|
220
|
+
Effect.runSync(Scope.close(scope, Exit.void))
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
1
|
import { spawnSync } from 'node:child_process'
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
-
import { tmpdir } from 'node:os'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
-
import { ARTIFACT_MAX_BYTES } from './artifact-pull.js'
|
|
7
|
-
import { cleanupAll, throwWithCleanup } from './cleanup.js'
|
|
8
|
-
import { authedFetch } from './http.js'
|
|
9
2
|
import { sanitizeTerminalText } from './json-response.js'
|
|
10
|
-
function
|
|
3
|
+
export function unzipRunnerArchive(zipPath: string, runnerDir: string): void {
|
|
11
4
|
const listing = spawnSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
|
|
12
5
|
if (listing.error) throw listing.error
|
|
13
6
|
if (listing.status !== 0) throw new Error(`unzip listing failed: ${sanitizeTerminalText(listing.stderr || listing.stdout || '')}`)
|
|
@@ -24,33 +17,27 @@ function unzip(zipPath: string, runnerDir: string): void {
|
|
|
24
17
|
export async function materializeRunnerVersion(runnerVersionId: string, verifyRegisteredDigest = true): Promise<{
|
|
25
18
|
runnerDir: string; cleanup: () => void
|
|
26
19
|
}> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|| typeof detail.contentSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(detail.contentSha256)) {
|
|
34
|
-
throw new Error('Runner version detail is invalid')
|
|
35
|
-
}
|
|
36
|
-
contentSha256 = detail.contentSha256
|
|
37
|
-
}
|
|
38
|
-
const response = await authedFetch(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}/download`)
|
|
39
|
-
if (!response.ok) throw new Error(`Failed to download runner version: ${await response.text()}`)
|
|
40
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'orizu-runner-version-'))
|
|
41
|
-
const cleanup = () => rmSync(tempDir, { recursive: true, force: true })
|
|
20
|
+
// ORI-2429: the download runs on Effect (runner-folders.ts); this adapter
|
|
21
|
+
// keeps the { runnerDir, cleanup } shape for the existing Promise callers.
|
|
22
|
+
const { downloadedRunner, openScope, RemovalLedger } = await import('./runner-folders.js')
|
|
23
|
+
const { Effect, Scope } = await import('effect')
|
|
24
|
+
const ledger = new RemovalLedger()
|
|
25
|
+
const { scope, close } = openScope()
|
|
42
26
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
27
|
+
const { runnerDir } = await Effect.runPromise(
|
|
28
|
+
downloadedRunner(runnerVersionId, ledger, verifyRegisteredDigest).pipe(Effect.provideService(Scope.Scope, scope))
|
|
29
|
+
)
|
|
30
|
+
return {
|
|
31
|
+
runnerDir,
|
|
32
|
+
cleanup: () => {
|
|
33
|
+
close()
|
|
34
|
+
const [failure] = ledger.errors()
|
|
35
|
+
if (failure !== undefined) throw failure
|
|
36
|
+
},
|
|
37
|
+
}
|
|
50
38
|
} catch (error) {
|
|
51
|
-
|
|
39
|
+
// downloadedRunner already removed this runner's folder on its own failure.
|
|
40
|
+
close()
|
|
41
|
+
throw error
|
|
52
42
|
}
|
|
53
43
|
}
|
|
54
|
-
export function cleanupMaterializedRunners(cleanups: Array<() => void>): void {
|
|
55
|
-
cleanupAll(cleanups, 'Failed to clean materialized runner directories')
|
|
56
|
-
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** ORI-2653: `orizu session publish`. In a hosted session, `git push` reaches
|
|
2
|
+
* only the session's own copy; this command saves the work there, then asks the
|
|
3
|
+
* web app to merge that commit into the team's main (the publish merge job app
|
|
4
|
+
* publishing already uses). It says "Published" only when the merge landed. */
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
import { isValidCloudflareArtifactsGitRemote } from './cloudflare-artifacts-git-remote.js'
|
|
8
|
+
import { authedFetch } from './http.js'
|
|
9
|
+
import { sanitizeTerminalText } from './json-response.js'
|
|
10
|
+
import { findWorkspaceRoot } from './workspace.js'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_MESSAGE = 'Publish from session'
|
|
13
|
+
// The web app waits up to 100 s for the merge; leave room for the reply.
|
|
14
|
+
const PUBLISH_TIMEOUT_MS = 170_000
|
|
15
|
+
// The codes the publish route words itself; their text already says what to do.
|
|
16
|
+
const PUBLISH_CODES = new Set(['publish_conflict', 'publish_race_lost', 'publish_still_running', 'publish_denied', 'publish_unavailable', 'publish_failed'])
|
|
17
|
+
const CHECK_AGAIN = 'It may have landed: wait a moment, then run `orizu session publish` again to check.'
|
|
18
|
+
|
|
19
|
+
export interface SessionPublishIo {
|
|
20
|
+
json: boolean
|
|
21
|
+
print: (line: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadAppPublishWorkspace() {
|
|
29
|
+
return import('./app-publish-workspace.js')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every answer that is not a confirmed merge: its text already leads with
|
|
33
|
+
* "Not published" or "Not confirmed" and ends with what to do. */
|
|
34
|
+
function refuse(io: SessionPublishIo, text: string, code: string): number {
|
|
35
|
+
const safe = sanitizeTerminalText(text)
|
|
36
|
+
io.print(io.json ? JSON.stringify({ error: safe, code }) : safe)
|
|
37
|
+
return 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when `origin` is this session's own Artifacts copy. In an older
|
|
41
|
+
* GitHub-backed session `origin` is the team's repository itself, where a push
|
|
42
|
+
* of main would skip the merge and its checks. Reads the configured URL, not
|
|
43
|
+
* one rewritten by git's `insteadOf`. */
|
|
44
|
+
function originIsThisSessionCopy(root: string, sessionId: string): boolean {
|
|
45
|
+
const read = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8' })
|
|
46
|
+
const remote = read.status === 0 ? read.stdout.trim() : ''
|
|
47
|
+
return isValidCloudflareArtifactsGitRemote(remote) &&
|
|
48
|
+
new URL(remote).pathname.endsWith(`/hosted-session-${sessionId.replaceAll('-', '')}.git`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runSessionPublish(io: SessionPublishIo, options: { message?: string | null } = {}): Promise<number> {
|
|
52
|
+
const sessionId = process.env.ORIZU_SESSION_ID?.trim()
|
|
53
|
+
const root = sessionId ? findWorkspaceRoot(process.cwd()) : null
|
|
54
|
+
if (!sessionId || !root) {
|
|
55
|
+
return refuse(io, 'Not published: orizu session publish runs inside a hosted session, from its workspace. In your own clone, push to your team repository instead.', 'publish_not_hosted')
|
|
56
|
+
}
|
|
57
|
+
if (!originIsThisSessionCopy(root, sessionId)) {
|
|
58
|
+
return refuse(io, "Not published: this workspace's origin is not this session's own copy, so publishing from here could write to the team's repository directly. Stop and tell the user.", 'publish_not_session_copy')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { addTeamCopyRemote, pushSessionCopy } = await loadAppPublishWorkspace()
|
|
62
|
+
let pushed: { sha: string; committed: string[] }
|
|
63
|
+
try {
|
|
64
|
+
pushed = await pushSessionCopy(root, options.message?.trim() || DEFAULT_MESSAGE)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return refuse(io, `Not published: ${error instanceof Error ? error.message : String(error)}`, 'publish_not_sent')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let response: Response
|
|
70
|
+
let text: string
|
|
71
|
+
try {
|
|
72
|
+
response = await authedFetch(`/api/cli/sessions/${encodeURIComponent(sessionId)}/publish`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ sessionCommitSha: pushed.sha }),
|
|
76
|
+
signal: AbortSignal.timeout(PUBLISH_TIMEOUT_MS),
|
|
77
|
+
})
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
80
|
+
return refuse(io, `Not confirmed: could not reach Orizu (${reason}). Your work is saved in this session's copy. ${CHECK_AGAIN}`, 'publish_unreachable')
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
text = await response.text()
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// The merge may have run before the answer was cut off.
|
|
86
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
87
|
+
return refuse(io, `Not confirmed: could not read the answer from Orizu (${reason}). Your work is saved in this session's copy. ${CHECK_AGAIN}`, 'publish_unreachable')
|
|
88
|
+
}
|
|
89
|
+
let body: unknown = null
|
|
90
|
+
try {
|
|
91
|
+
body = JSON.parse(text)
|
|
92
|
+
} catch {
|
|
93
|
+
// An answer that is not JSON is reported below as it came.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (response.ok && isRecord(body) && (body.outcome === 'merged' || body.outcome === 'already_merged') && typeof body.sha === 'string') {
|
|
97
|
+
const sha = sanitizeTerminalText(body.sha)
|
|
98
|
+
if (io.json) {
|
|
99
|
+
io.print(JSON.stringify({ outcome: body.outcome, sha }))
|
|
100
|
+
} else if (body.outcome === 'merged') {
|
|
101
|
+
const files = pushed.committed.map(file => `\n ${sanitizeTerminalText(file)}`).join('')
|
|
102
|
+
io.print(`Published: merged into the team's main as ${sha.slice(0, 7)}. New sessions on this project will see it.${files ? `\nCommitted first:${files}` : ''}`)
|
|
103
|
+
} else {
|
|
104
|
+
io.print(`Already on the team's main (${sha.slice(0, 7)}). Nothing new to publish.`)
|
|
105
|
+
}
|
|
106
|
+
return 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isRecord(body) && typeof body.error === 'string' && typeof body.code === 'string' && PUBLISH_CODES.has(body.code)) {
|
|
110
|
+
const code = body.code
|
|
111
|
+
let message = body.error
|
|
112
|
+
if (code === 'publish_conflict') {
|
|
113
|
+
const remoteNote = await addTeamCopyRemote(root)
|
|
114
|
+
// Its own line: the recovery line above must stay a runnable command.
|
|
115
|
+
if (remoteNote) message = `${message}\n${remoteNote}`
|
|
116
|
+
}
|
|
117
|
+
return refuse(io, message, code)
|
|
118
|
+
}
|
|
119
|
+
// Any other answer is not worded for this command. A 4xx refused the request
|
|
120
|
+
// before any merge; a 5xx, or a 2xx that is not the route's answer, may have
|
|
121
|
+
// come after one.
|
|
122
|
+
const said = sanitizeTerminalText(isRecord(body) && typeof body.error === 'string' ? body.error : text.slice(0, 300))
|
|
123
|
+
if (response.status >= 400 && response.status < 500) {
|
|
124
|
+
return refuse(io, `Not published: Orizu refused the request (${response.status}: ${said}). Stop and tell the user.`, `http_${response.status}`)
|
|
125
|
+
}
|
|
126
|
+
return refuse(io, `Not confirmed: Orizu answered ${response.status} (${said}). ${CHECK_AGAIN}`, `http_${response.status}`)
|
|
127
|
+
}
|
|
@@ -54,6 +54,29 @@ const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const
|
|
|
54
54
|
// status write); past this it is killed so nothing is left running (ORI-2402).
|
|
55
55
|
const CHILD_STOP_WAIT_MS = 15_000
|
|
56
56
|
|
|
57
|
+
export interface SkilledProposerStopOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Runs once the optimizer has exited after a stop signal, just before the
|
|
60
|
+
* CLI ends by that signal (ORI-2429: the caller removes its runner copies).
|
|
61
|
+
* It gets whatever is left of CHILD_STOP_WAIT_MS since the signal; a hook
|
|
62
|
+
* still running then is abandoned so the stop cannot hang. A synchronous
|
|
63
|
+
* hook always runs to the end, even with no time left.
|
|
64
|
+
*/
|
|
65
|
+
beforeSignalExit?: () => Promise<void> | void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Wait for `hook` at most until `deadline`; its failure never blocks the exit.
|
|
69
|
+
async function runBeforeSignalExit(hook: SkilledProposerStopOptions['beforeSignalExit'], deadline: number): Promise<void> {
|
|
70
|
+
if (!hook) return
|
|
71
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
72
|
+
const expired = new Promise<void>(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now())) })
|
|
73
|
+
try {
|
|
74
|
+
await Promise.race([Promise.resolve().then(hook).catch(() => {}), expired])
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
/**
|
|
58
81
|
* Run the optimizer, passing a stop signal on to it. On a stop signal the CLI
|
|
59
82
|
* waits until the optimizer exits (capped at CHILD_STOP_WAIT_MS, then kills
|
|
@@ -63,11 +86,13 @@ export async function spawnSkilledProposerChild(
|
|
|
63
86
|
command: string, args: string[], engine: GepaEngine, environment: NodeJS.ProcessEnv,
|
|
64
87
|
onSpawn?: () => Promise<void>,
|
|
65
88
|
output?: SkilledProposerChildOutput,
|
|
89
|
+
stopOptions: SkilledProposerStopOptions = {},
|
|
66
90
|
) {
|
|
67
91
|
const childEnvironment = { ...environment }
|
|
68
92
|
let child: ReturnType<typeof spawn> | PipedChild | undefined
|
|
69
93
|
let cleanup = () => {}
|
|
70
94
|
let stopSignal: NodeJS.Signals | undefined
|
|
95
|
+
let stopDeadline = 0
|
|
71
96
|
// On the terminal path the optimizer leads its own process group, so a stop
|
|
72
97
|
// also reaches its runners, which may run in worker threads it can't stop.
|
|
73
98
|
let childGroup: number | undefined
|
|
@@ -79,10 +104,16 @@ export async function spawnSkilledProposerChild(
|
|
|
79
104
|
}
|
|
80
105
|
let giveUp: ReturnType<typeof setTimeout> | undefined
|
|
81
106
|
const removeHandlers = () => { for (const entry of handlers) process.off(entry.signal, entry.handle) }
|
|
82
|
-
|
|
107
|
+
let ending = false
|
|
108
|
+
const endBySignal = async (signal: NodeJS.Signals): Promise<never> => {
|
|
109
|
+
if (ending) return new Promise<never>(() => {})
|
|
110
|
+
ending = true
|
|
83
111
|
if (giveUp) clearTimeout(giveUp)
|
|
84
|
-
removeHandlers()
|
|
85
112
|
cleanup()
|
|
113
|
+
// Our handlers stay installed during the caller's cleanup, so a second
|
|
114
|
+
// Ctrl-C then is absorbed instead of ending the CLI by default mid-cleanup.
|
|
115
|
+
await runBeforeSignalExit(stopOptions.beforeSignalExit, stopDeadline)
|
|
116
|
+
removeHandlers()
|
|
86
117
|
// With our handlers gone, the signal's default action ends the process.
|
|
87
118
|
process.kill(process.pid, signal)
|
|
88
119
|
return new Promise<never>(() => {})
|
|
@@ -90,6 +121,7 @@ export async function spawnSkilledProposerChild(
|
|
|
90
121
|
const handlers = STOP_SIGNALS.map(signal => ({
|
|
91
122
|
signal,
|
|
92
123
|
handle: () => {
|
|
124
|
+
if (!stopDeadline) stopDeadline = Date.now() + CHILD_STOP_WAIT_MS
|
|
93
125
|
if (!child) { void endBySignal(signal); return }
|
|
94
126
|
forward(signal)
|
|
95
127
|
if (stopSignal) return
|
|
@@ -104,6 +136,8 @@ export async function spawnSkilledProposerChild(
|
|
|
104
136
|
try {
|
|
105
137
|
cleanup = materializeConfigPayload(engine, childEnvironment) ?? cleanup
|
|
106
138
|
await new Promise<void>(resolve => setImmediate(resolve))
|
|
139
|
+
// A stop before the optimizer starts ends the CLI (endBySignal); never start it then.
|
|
140
|
+
if (stopDeadline) return await new Promise<never>(() => {})
|
|
107
141
|
let result: { status: number | null, error?: Error }
|
|
108
142
|
if (output) {
|
|
109
143
|
result = await runPipedChild(
|
|
@@ -112,7 +146,11 @@ export async function spawnSkilledProposerChild(
|
|
|
112
146
|
childEnvironment,
|
|
113
147
|
async () => { await onSpawn?.() },
|
|
114
148
|
output,
|
|
115
|
-
spawnedChild => {
|
|
149
|
+
spawnedChild => {
|
|
150
|
+
child = spawnedChild
|
|
151
|
+
// Stopped while the output sinks were prepared: the CLI is ending.
|
|
152
|
+
if (stopDeadline && !stopSignal) spawnedChild.kill('SIGKILL')
|
|
153
|
+
}
|
|
116
154
|
)
|
|
117
155
|
} else {
|
|
118
156
|
// stdin is not passed on: a process outside the terminal's foreground
|
package/src/workbench-cli.ts
CHANGED
|
@@ -336,6 +336,7 @@ const SESSION_START_OPTIONS = new Set([
|
|
|
336
336
|
'--session',
|
|
337
337
|
'--json',
|
|
338
338
|
])
|
|
339
|
+
const SESSION_PUBLISH_OPTIONS = new Set(['--message', '--json'])
|
|
339
340
|
const WORKBENCH_VALUE_OPTIONS = new Set([
|
|
340
341
|
'--after',
|
|
341
342
|
'--interval',
|
|
@@ -1221,7 +1222,7 @@ function terminalStatusForCommand(command: string): string | null {
|
|
|
1221
1222
|
}
|
|
1222
1223
|
|
|
1223
1224
|
const USAGE_LINE =
|
|
1224
|
-
'Usage: orizu session <start|status|end|finish> ... | orizu run <start|status|tail|complete|fail|cancel> ... (run tail --once drains all currently persisted events, then exits without following)'
|
|
1225
|
+
'Usage: orizu session <start|status|end|finish|publish> ... | orizu run <start|status|tail|complete|fail|cancel> ... (run tail --once drains all currently persisted events, then exits without following)'
|
|
1225
1226
|
|
|
1226
1227
|
// In --json mode ANY command failure — bad usage, or an operational failure
|
|
1227
1228
|
// such as a 401/403/404/409/network error surfaced by assertOk — must still
|
|
@@ -1268,6 +1269,18 @@ async function dispatchWorkbenchCommand(args: string[], io: WorkbenchCommandIo):
|
|
|
1268
1269
|
// `--workspace <dir>`: the workbench clone directory when the command is
|
|
1269
1270
|
// not run from inside it (defaults to cwd).
|
|
1270
1271
|
const workspaceDir = argValue(args, '--workspace') ?? undefined
|
|
1272
|
+
if (subcommand === 'publish') {
|
|
1273
|
+
const unknownOption = findUnknownOption(args, SESSION_PUBLISH_OPTIONS, WORKBENCH_VALUE_OPTIONS)
|
|
1274
|
+
if (unknownOption) {
|
|
1275
|
+
return emitJsonError(io, `unknown option ${unknownOption}\nUsage: orizu session publish [--message <text>] [--json]`)
|
|
1276
|
+
}
|
|
1277
|
+
const stray = positional[2]
|
|
1278
|
+
if (stray !== undefined) {
|
|
1279
|
+
return emitJsonError(io, `unexpected argument ${stray}\nUsage: orizu session publish [--message <text>] [--json]`)
|
|
1280
|
+
}
|
|
1281
|
+
const { runSessionPublish } = await import('./session-publish.js')
|
|
1282
|
+
return runSessionPublish(io, { message: argValue(args, '--message') })
|
|
1283
|
+
}
|
|
1271
1284
|
if (subcommand === 'start') {
|
|
1272
1285
|
const unknownOption = findUnknownOption(args, SESSION_START_OPTIONS, WORKBENCH_VALUE_OPTIONS)
|
|
1273
1286
|
if (unknownOption) {
|
|
@@ -10,7 +10,7 @@ This is the single source of truth for who runs each action. Replace placeholder
|
|
|
10
10
|
| Local agent under the user's token | Workflow | Survey the customer codebase, resolve state, prepare the plan, files, manifests, diffs, and evidence, then ask the human to ratify the workflow design. | The human moments are plan ratification and execution of all production/default pointer moves. |
|
|
11
11
|
| Local agent under the user's token | Catalog mutations and pointer preparation | After plan ratification, pull instruction material from the customer codebase and run `orizu instructions create <manifest> --project <team/project>`, `orizu instructions push <manifest> --project <team/project> --set <slug-or-exact-name>`, shape add/remove before first promotion, archive/restore itself under the user's token. For pointer changes, gather only read-only resolution and binding evidence, prepare the exact commands, then hand them off. | Per the Micro-round 3 custody ruling, a human admin or curator using a user token executes every runner default-label move, optimizer-label move, scorer production-label move, profile promote/rollback, default move, and labeled optimization promotion on every surface. |
|
|
12
12
|
| Hosted session | Start and scope | Use the pre-authenticated CLI from `ORIZU_TOKEN_FILE` and `ORIZU_BASE_URL`; resolve only the session's signed team with `orizu teams list`, then list or create projects only within it. Run `orizu projects create --name "<project name>" --team <signed-team>` when needed. | Team creation is platform-enforced human-only. Hand off: `orizu teams create --name "<team name>"`, then ask the human to start a new hosted session scoped to that team; the current session cannot switch teams. |
|
|
13
|
-
| Hosted session | Team-wide curator work | While the hosted session is active and its kill switch is clear, perform setup plus ordinary curator reads and writes within the signed team. Run `orizu instructions profiles new <set> --project <team/project> --model-config <identity>` and `orizu instructions sync <set> --project <team/project> --out <app-root>`; synced material lands under `<app-root>/orizu/`. For git-canonical judges, scorers, runners, and optimizers, write with `--session <session-id>`, keep bytes as commits on the session branch, then verify with `git ls-files <path>` and inspect the commit. | The agent executes this work; an Orizu version id alone is not proof of committed bytes. Signed-project exceptions are in the next row. |
|
|
13
|
+
| Hosted session | Team-wide curator work | While the hosted session is active and its kill switch is clear, perform setup plus ordinary curator reads and writes within the signed team. Run `orizu instructions profiles new <set> --project <team/project> --model-config <identity>` and `orizu instructions sync <set> --project <team/project> --out <app-root>`; synced material lands under `<app-root>/orizu/`. For git-canonical judges, scorers, runners, and optimizers, write with `--session <session-id>`, keep bytes as commits on the session branch, then verify with `git ls-files <path>` and inspect the commit. Run `orizu session publish` to put committed work on the team's main; `git push` reaches only the session's copy. | The agent executes this work; an Orizu version id alone is not proof of committed bytes. Signed-project exceptions are in the next row. |
|
|
14
14
|
| Hosted session | Signed-project work | Run optimization mutations only for the JWT's project, keep git-canonical draft writes on that session and project, and read diff comments only for that project. | Sibling-project optimization mutations, draft writes, and diff-comment reads are out of scope even where ordinary curator access is team-wide. |
|
|
15
15
|
| Hosted session | Materialize an instruction set | When the survey finds only a candidate seed, turn it into the complete manifest. | ADR-008 requires a human curator using a local CLI user token. Create/push are hosted CLI route-enforced; direct RPC remains agent-writable and is a fix-forward gap. Hand off `orizu instructions create <manifest> --project <team/project>` for a fresh set, or `orizu instructions push <manifest> --project <team/project> --set <slug-or-exact-name>` for an existing set. Their measured 403 is `Sessionless registration is not available to the hosted agent: this artifact class is git-canonical (ADR-007) and its bytes must be committed to the session branch. Re-run with --session <session-id>.` Neither create nor push accepts `--session`. |
|
|
16
16
|
| Hosted session | Profiles and component shape | Run `orizu instructions profiles new <set> --project <team/project> --model-config <identity>`, `orizu instructions shape add <set> --project <team/project> --key <key> --from <manifest>`, and `orizu instructions shape remove <set> --project <team/project> --key <key>` with team-scoped curator authority. Shape add/remove works only until the first promotion of any profile, creates unpromoted profile heads, and does not move pointers. First promotion permanently freezes the shape, and sets that existed when this rule shipped are frozen too; a changed frozen shape requires a new instruction set. | A human admin or curator with `can_manage_project` runs every profile promote/rollback, instruction-set default move, and other production/default pointer move. |
|
|
@@ -1620,7 +1620,7 @@ In a hosted session, `orizu apps create` and `orizu apps update` publish through
|
|
|
1620
1620
|
|
|
1621
1621
|
Edit an existing app in its folder and pass those files to `apps update`. A new app can live anywhere until `apps create` places it.
|
|
1622
1622
|
|
|
1623
|
-
The merge includes all earlier committed work on the session branch, including files outside the app folder. Review that work before publishing. Unrelated uncommitted work stays local. A conflict may name files outside the app folder; resolve and commit those files too.
|
|
1623
|
+
The merge includes all earlier committed work on the session branch, including files outside the app folder. Review that work before publishing. Unrelated uncommitted work stays local. To publish work that is not an app (notes, memory, other files), use `orizu session publish` (see [cli-reference.md](cli-reference.md#publishing-hosted-work)). A conflict may name files outside the app folder; resolve and commit those files too.
|
|
1624
1624
|
|
|
1625
1625
|
When a hosted `apps create` or `apps update` stops after its folder is known, its error prints `App ID`, `Folder`, and a full `Retry` command. Keep that ID and folder. After all three app files have been written, the retry uses safe canonical files, preserving committed conflict resolutions. If the attempt stops before all three files are written, the retry keeps the original input paths. Both retries preserve the selected server. After following the error's instructions, run the printed retry command; create keeps its original `--id`, and update keeps its `--app`. This also applies when the push is rejected, the API refuses the create, or the merge is still running. The files and commits already made stay in the workspace; a refused push has not sent them to the server.
|
|
1626
1626
|
|
|
@@ -35,6 +35,22 @@ Behavior:
|
|
|
35
35
|
- PAT authorization follows the owning user's current team/project roles, so demotion or removal takes effect without rotating the token.
|
|
36
36
|
- A hosted session is pre-authenticated via `ORIZU_TOKEN_FILE` (no `orizu login` needed); an explicit `ORIZU_TOKEN` env var overrides both the token file and stored credentials when set.
|
|
37
37
|
|
|
38
|
+
### Publishing hosted work
|
|
39
|
+
|
|
40
|
+
In a hosted session, `git push` and the automatic save reach only the session's own copy of the team repository. Run this to put the work on the team's main:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
orizu session publish [--message <text>] [--json]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- It commits any uncommitted work (the staged Orizu skill links are left out), pushes the session's copy, then merges it into the team's main. New sessions on the project start from that main. The merge includes all earlier committed work on the session branch, including the automatic saves; review that work before publishing.
|
|
47
|
+
- It prints `Published: merged into the team's main as <sha>` or `Already on the team's main (<sha>)` only when the work is there. Never tell the user work is published without one of those lines.
|
|
48
|
+
- Anything else starts with `Not published:` (the work cannot have landed) or `Not confirmed:` (it may have), and ends with what to do:
|
|
49
|
+
- **Conflict** (names the files): run the printed `Recovery` command, resolve those files, commit, then run `orizu session publish` again.
|
|
50
|
+
- **Session copy has commits this workspace does not**: run `git pull --no-rebase --no-edit`, then publish again.
|
|
51
|
+
- **Not confirmed, or other publishes landed first**: wait a moment and publish again; a merge that landed answers `Already on the team's main`.
|
|
52
|
+
- **Stop and tell the user**: the session ended, the team paused agents, publishing is not available for this session, or the workspace's origin is not the session's own copy.
|
|
53
|
+
|
|
38
54
|
### Product feedback
|
|
39
55
|
|
|
40
56
|
```bash
|