orizu 0.5.37 → 0.5.39

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 (98) hide show
  1. package/README.md +18 -3
  2. package/dist/app-commands.d.ts.map +1 -1
  3. package/dist/app-commands.js +215 -57
  4. package/dist/app-publish-failure.d.ts +6 -0
  5. package/dist/app-publish-failure.d.ts.map +1 -0
  6. package/dist/app-publish-failure.js +9 -0
  7. package/dist/app-publish-paths.d.ts +4 -0
  8. package/dist/app-publish-paths.d.ts.map +1 -0
  9. package/dist/app-publish-paths.js +32 -0
  10. package/dist/app-publish-workspace.d.ts +16 -0
  11. package/dist/app-publish-workspace.d.ts.map +1 -0
  12. package/dist/app-publish-workspace.js +175 -0
  13. package/dist/artifacts-git-runtime.d.ts +5 -0
  14. package/dist/artifacts-git-runtime.d.ts.map +1 -1
  15. package/dist/artifacts-git-runtime.js +70 -25
  16. package/dist/artifacts-session-merge.d.ts +62 -0
  17. package/dist/artifacts-session-merge.d.ts.map +1 -0
  18. package/dist/artifacts-session-merge.js +123 -0
  19. package/dist/dataset-append-workflow.d.ts +12 -0
  20. package/dist/dataset-append-workflow.d.ts.map +1 -0
  21. package/dist/dataset-append-workflow.js +198 -0
  22. package/dist/dataset-commands.d.ts.map +1 -1
  23. package/dist/dataset-commands.js +11 -130
  24. package/dist/dataset-transfer-download.js +6 -6
  25. package/dist/dataset-transfer-upload.js +8 -8
  26. package/dist/git-credential.d.ts +1 -1
  27. package/dist/git-credential.d.ts.map +1 -1
  28. package/dist/git-credential.js +8 -1
  29. package/dist/help.js +3 -3
  30. package/dist/hosted-boot.d.ts +11 -0
  31. package/dist/hosted-boot.d.ts.map +1 -1
  32. package/dist/hosted-boot.js +46 -1
  33. package/dist/hosted-harvest.d.ts.map +1 -1
  34. package/dist/hosted-harvest.js +11 -1
  35. package/dist/hosted-session-cli.d.ts.map +1 -1
  36. package/dist/hosted-session-cli.js +6 -1
  37. package/dist/hosted-session-seed.d.ts +45 -0
  38. package/dist/hosted-session-seed.d.ts.map +1 -0
  39. package/dist/hosted-session-seed.js +61 -0
  40. package/dist/hosted-skill-staging.d.ts +8 -3
  41. package/dist/hosted-skill-staging.d.ts.map +1 -1
  42. package/dist/hosted-skill-staging.js +10 -3
  43. package/dist/jsonl-stream.d.ts +1 -0
  44. package/dist/jsonl-stream.d.ts.map +1 -1
  45. package/dist/jsonl-stream.js +7 -0
  46. package/dist/provider-list-workflow.d.ts +3 -0
  47. package/dist/provider-list-workflow.d.ts.map +1 -0
  48. package/dist/provider-list-workflow.js +95 -0
  49. package/dist/provider-wire-record.d.ts +3 -0
  50. package/dist/provider-wire-record.d.ts.map +1 -0
  51. package/dist/provider-wire-record.js +4 -0
  52. package/dist/providers-cli.d.ts.map +1 -1
  53. package/dist/providers-cli.js +5 -15
  54. package/dist/publish-merge-job.d.ts +68 -0
  55. package/dist/publish-merge-job.d.ts.map +1 -0
  56. package/dist/publish-merge-job.js +320 -0
  57. package/dist/publish-merge-timing.d.ts +18 -0
  58. package/dist/publish-merge-timing.d.ts.map +1 -0
  59. package/dist/publish-merge-timing.js +23 -0
  60. package/dist/workbench-cli.d.ts +4 -0
  61. package/dist/workbench-cli.d.ts.map +1 -1
  62. package/dist/workbench-cli.js +11 -1
  63. package/dist/workspace-bootstrap-job.d.ts +1 -0
  64. package/dist/workspace-bootstrap-job.d.ts.map +1 -1
  65. package/dist/workspace-bootstrap-job.js +1 -1
  66. package/package.json +5 -2
  67. package/src/app-commands.ts +234 -66
  68. package/src/app-publish-failure.ts +7 -0
  69. package/src/app-publish-paths.ts +27 -0
  70. package/src/app-publish-workspace.ts +207 -0
  71. package/src/artifacts-git-runtime.ts +73 -31
  72. package/src/artifacts-session-merge.ts +253 -0
  73. package/src/dataset-append-workflow.ts +205 -0
  74. package/src/dataset-commands.ts +10 -170
  75. package/src/dataset-transfer-download.ts +5 -5
  76. package/src/dataset-transfer-upload.ts +7 -7
  77. package/src/git-credential.ts +8 -1
  78. package/src/help.ts +3 -3
  79. package/src/hosted-boot.ts +56 -1
  80. package/src/hosted-harvest.ts +11 -1
  81. package/src/hosted-session-cli.ts +6 -1
  82. package/src/hosted-session-seed.ts +78 -0
  83. package/src/hosted-skill-staging.ts +11 -3
  84. package/src/jsonl-stream.ts +8 -0
  85. package/src/provider-list-workflow.ts +108 -0
  86. package/src/provider-wire-record.ts +4 -0
  87. package/src/providers-cli.ts +5 -13
  88. package/src/publish-merge-job.ts +350 -0
  89. package/src/publish-merge-timing.ts +27 -0
  90. package/src/workbench-cli.ts +15 -1
  91. package/src/workspace-bootstrap-job.ts +1 -1
  92. package/vendor/skills/orizu/flows/onboard.md +1 -1
  93. package/vendor/skills/orizu/references/authority-map.md +2 -2
  94. package/vendor/skills/orizu/references/building-apps.md +24 -0
  95. package/vendor/skills/orizu/references/cli-reference.md +13 -4
  96. package/vendor/skills/orizu/references/optimization-reports.md +1 -1
  97. package/vendor/skills/orizu/references/primer.md +1 -0
  98. package/vendor/skills/orizu/references/prompt-control-plane.md +9 -5
@@ -0,0 +1,108 @@
1
+ import { Context, Effect, Result, Schema } from 'effect'
2
+
3
+ import { authedFetch } from './http.js'
4
+ import { providerWireRecord as record } from './provider-wire-record.js'
5
+ import { isProviderRefusalCode } from './provider-refusals.js'
6
+
7
+ import type { ModelConfigsCommandIo } from './model-configs-cli.js'
8
+
9
+ type ProviderListFailure =
10
+ | { readonly _tag: 'Http'; readonly message: string }
11
+ | { readonly _tag: 'Transport' }
12
+ | { readonly _tag: 'Resolution'; readonly message: string }
13
+ | { readonly _tag: 'Adapter'; readonly message: string }
14
+
15
+ function providerListFailureMessage(failure: ProviderListFailure): string {
16
+ switch (failure._tag) {
17
+ case 'Http': return failure.message
18
+ case 'Transport': return 'Provider request failed'
19
+ // Project selection remains the existing, separately owned adapter.
20
+ case 'Resolution': case 'Adapter': return failure.message
21
+ default: { const exhaustive: never = failure; return exhaustive }
22
+ }
23
+ }
24
+
25
+ const ProviderListTransport = Context.Service<{
26
+ readonly read: (project: string) => Effect.Effect<Response, ProviderListFailure>
27
+ }>('orizu/cli/ProviderListTransport')
28
+
29
+ const WireRecord = Schema.Record(Schema.String, Schema.Unknown)
30
+ const emptyWireRecord: Record<string, unknown> = {}
31
+
32
+ function providerListProgram(project: string, io: ModelConfigsCommandIo) {
33
+ return Effect.gen(function*() {
34
+ const transport = yield* ProviderListTransport
35
+ const response = yield* transport.read(project)
36
+ // Decode raw wire data; malformed JSON and non-record bodies retain the
37
+ // existing empty record fallback without narrowing accepted record fields.
38
+ const value = yield* Effect.promise(() => response.json().catch(() => null))
39
+ const body = yield* Schema.decodeUnknownEffect(WireRecord)(value).pipe(
40
+ Effect.catch(() => Effect.succeed(emptyWireRecord)),
41
+ )
42
+ if (!response.ok) {
43
+ const message = typeof body.error === 'string' && isProviderRefusalCode(body.error) ? body.error : 'Provider request failed'
44
+ return yield* Effect.fail<ProviderListFailure>({ _tag: 'Http', message })
45
+ }
46
+ yield* Effect.sync(() => {
47
+ if (io.json) io.print(JSON.stringify(body))
48
+ else {
49
+ io.print('ID PROTOCOL HOST CREDENTIAL ENV BUILT-IN')
50
+ for (const value of Array.isArray(body.providers) ? body.providers : []) {
51
+ const row = record(value)
52
+ io.print(`${row.id} ${row.protocol} ${row.host} ${row.credentialEnv} ${row.builtIn ? 'yes' : 'no'}`)
53
+ }
54
+ }
55
+ })
56
+ })
57
+ }
58
+
59
+ export async function listProviders(projectArg: string | null, io: ModelConfigsCommandIo): Promise<number> {
60
+ const controller = new AbortController()
61
+ let interrupted: 130 | 143 | undefined
62
+ const interrupt = () => { interrupted = 130; controller.abort() }
63
+ const terminate = () => { interrupted = 143; controller.abort() }
64
+ const transport = {
65
+ read: (project: string) => Effect.tryPromise({
66
+ try: signal => (io.fetcher ?? authedFetch)(`/api/cli/providers?project=${encodeURIComponent(project)}`, {
67
+ method: 'GET', signal: AbortSignal.any([signal, controller.signal]),
68
+ }),
69
+ // The retained HTTP adapter exposes login/config/refresh diagnostics as
70
+ // untyped Error values. Preserve its public messages until ORI-2420
71
+ // supplies typed adapter failures; never expose nested transport causes.
72
+ catch: (cause): ProviderListFailure => cause instanceof Error
73
+ ? { _tag: 'Adapter', message: cause.message }
74
+ : { _tag: 'Transport' },
75
+ }),
76
+ }
77
+ const program = Effect.gen(function*() {
78
+ const project = yield* Effect.tryPromise({
79
+ try: async () => {
80
+ const project = await io.resolveProjectSlug?.(projectArg)
81
+ if (!project) throw new Error('Project resolver unavailable')
82
+ return project
83
+ },
84
+ catch: (cause): ProviderListFailure => ({ _tag: 'Resolution', message: cause instanceof Error ? cause.message : 'Provider request failed' }),
85
+ })
86
+ // The retained resolver is unsignalled. Do not suppress Node's natural
87
+ // termination until the cancellable catalog transport has been admitted.
88
+ return yield* Effect.acquireUseRelease(
89
+ Effect.sync(() => { process.once('SIGINT', interrupt); process.once('SIGTERM', terminate) }),
90
+ () => providerListProgram(project, io).pipe(Effect.provideService(ProviderListTransport, transport)),
91
+ () => Effect.sync(() => {
92
+ process.removeListener('SIGINT', interrupt)
93
+ process.removeListener('SIGTERM', terminate)
94
+ }),
95
+ )
96
+ })
97
+ try {
98
+ const result = await Effect.runPromise(Effect.result(program), { signal: controller.signal })
99
+ if (Result.isSuccess(result)) return 0
100
+ const message = providerListFailureMessage(result.failure)
101
+ io.print(io.json ? JSON.stringify({ error: message }) : message)
102
+ return 1
103
+ } catch {
104
+ if (interrupted !== undefined) return interrupted
105
+ io.print(io.json ? JSON.stringify({ error: 'Provider request failed' }) : 'Provider request failed')
106
+ return 1
107
+ }
108
+ }
@@ -0,0 +1,4 @@
1
+ /** Preserve the provider commands' permissive ordinary-TypeScript row coercion. */
2
+ export function providerWireRecord(value: unknown): Record<string, unknown> {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
4
+ }
@@ -1,4 +1,5 @@
1
1
  import { authedFetch } from './http.js'
2
+ import { providerWireRecord as object } from './provider-wire-record.js'
2
3
  import { credentialEnvFor, providerLookupFor } from './provider-lookup.js'
3
4
  import { CREDENTIAL_ENV_PATTERN, PROVIDER_FIELD_PATTERNS, RESERVED_CREDENTIAL_ENVS, isProviderRefusalCode } from './provider-refusals.js'
4
5
  import { findProvider, parseModelIdentity } from './provider-registry.js'
@@ -8,9 +9,6 @@ import type { ModelConfigsCommandIo } from './model-configs-cli.js'
8
9
  import type { ProviderEntry, WireProtocol } from './provider-registry.js'
9
10
 
10
11
  const FIELD_CONTRACT = 'Anthropic Messages base URL excludes /v1; OpenAI base URLs include the version segment.'
11
- function object(value: unknown): Record<string, unknown> {
12
- return typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
13
- }
14
12
  function assertProtocol(value: unknown): asserts value is WireProtocol {
15
13
  if (typeof value !== 'string' || !Object.hasOwn(PROBE_PROTOCOLS, value)) throw new Error('provider_protocol_invalid')
16
14
  }
@@ -89,6 +87,10 @@ export async function providersCommand(args: string[], io: ModelConfigsCommandIo
89
87
  baseUrl = baseUrl.replace(/^(https:\/\/)([^/]+)/, (_, scheme: string, host: string) => scheme + host.toLowerCase())
90
88
  provider = providerRow({ id, baseUrl, protocol: flags.get('--protocol') ?? 'openai-chat', credentialEnv: flags.get('--credential-env') ?? credentialEnvFor(id), authHeader: flags.get('--auth-header') ?? 'Authorization', authValuePrefix: flags.get('--auth-value-prefix') ?? 'Bearer ' })
91
89
  }
90
+ if (checkIdentity === undefined && command === 'list' && positional.length === 1) {
91
+ const { listProviders } = await import('./provider-list-workflow.js')
92
+ return listProviders(flags.get('--project') || null, io)
93
+ }
92
94
  const project = await io.resolveProjectSlug?.(flags.get('--project') || null)
93
95
  if (!project) throw new Error('Project resolver unavailable')
94
96
  const fetcher = io.fetcher ?? authedFetch
@@ -116,16 +118,6 @@ export async function providersCommand(args: string[], io: ModelConfigsCommandIo
116
118
  const entry = lookup.find(identity.provider)
117
119
  if (!entry) throw new Error('provider_not_found')
118
120
  await runProbe(entry, identity.modelId, io)
119
- } else if (command === 'list' && positional.length === 1) {
120
- const body = await request(root, { method: 'GET' })
121
- if (io.json) io.print(JSON.stringify(body))
122
- else {
123
- io.print('ID PROTOCOL HOST CREDENTIAL ENV BUILT-IN')
124
- for (const value of Array.isArray(body.providers) ? body.providers : []) {
125
- const row = object(value)
126
- io.print(`${row.id} ${row.protocol} ${row.host} ${row.credentialEnv} ${row.builtIn ? 'yes' : 'no'}`)
127
- }
128
- }
129
121
  } else if (command === 'remove' && id && positional.length === 2) {
130
122
  await request(`/api/cli/providers/${encodeURIComponent(id)}?project=${encodeURIComponent(project)}`, { method: 'DELETE' })
131
123
  io.print(io.json ? JSON.stringify({ removed: id }) : `Removed provider ${id}`)
@@ -0,0 +1,350 @@
1
+ /** ORI-2280: the agent-free publish merge one-shot. The coordinator runs it
2
+ * in a sandbox with network access to Artifacts only. It merges one session
3
+ * copy into its team copy with `publishSessionMerge`, getting one scoped key
4
+ * at a time from the web app and revoking every key before it reports. */
5
+ import { execFile } from 'node:child_process'
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { promisify } from 'node:util'
10
+
11
+ import { createEphemeralAskPass, type GitAuth } from './artifacts-git-runtime.js'
12
+ import { publishSessionMerge, type PublishSessionMergeResult } from './artifacts-session-merge.js'
13
+ import { CLOUDFLARE_ARTIFACTS_HOST_PATTERN } from './cloudflare-artifacts-git-remote.js'
14
+ import { assertSecureTokenTransport } from './http.js'
15
+ import { assertWorkspaceBootstrapRemote, isLoopbackOrigin } from './workspace-bootstrap-job.js'
16
+ import {
17
+ PUBLISH_MERGE_CONFLICT_FILES_LIMIT,
18
+ PUBLISH_MERGE_CONFLICT_PATH_MAX,
19
+ PUBLISH_MERGE_COORDINATOR_WALL_MS,
20
+ PUBLISH_MERGE_WALL_AT_ENV,
21
+ type PublishMergeJobFailureCode,
22
+ } from './publish-merge-timing.js'
23
+ import { DEFAULT_REPOSITORY_BACKUP_LIMITS } from './repository-backup-core.js'
24
+
25
+ const PUBLISH_MERGE_JOB_CAPABILITY = 'publish-merge-job:v1'
26
+ /** Timing. The job's wall is the earlier of:
27
+ * - ORIZU_PUBLISH_MERGE_WALL_AT, when set: the epoch-ms moment the
28
+ * coordinator stops the sandbox (its createdAt + 120 s, so sandbox create
29
+ * and CLI boot are already counted);
30
+ * - 110 s from the job's own start, the fallback when it is not set.
31
+ * Within it:
32
+ * - the merge ends by 90 s from start, and always 10 s before the wall;
33
+ * - revoking the keys still live ends 10 s before the wall, each call at
34
+ * most 15 s or whatever is left;
35
+ * - the last 10 s are kept for posting the result, so it always goes out.
36
+ * All of this is well below the read keys' five-minute life, so no key is
37
+ * ever renewed. */
38
+ const JOB_DEADLINE_MS = 90_000
39
+ const RESULT_RESERVE_MS = 10_000
40
+ const JOB_WALL_MS = PUBLISH_MERGE_COORDINATOR_WALL_MS - RESULT_RESERVE_MS
41
+ const LATE_CALL_TIMEOUT_MS = 15_000
42
+ const GIT_TIMEOUT_MS = 60_000
43
+ /** ADR-012's envelope for a repository's whole object graph. The graph is
44
+ * always larger than the content it was seeded from, so ADR-011's 1 GiB
45
+ * content limit is not the measure here. */
46
+ const ADMISSION = {
47
+ totalBytes: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxObjectBytes,
48
+ objectBytes: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxSingleObjectBytes,
49
+ objects: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxObjects,
50
+ commits: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxCommits,
51
+ }
52
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
53
+ const IDENTITY = {
54
+ GIT_AUTHOR_NAME: 'Orizu',
55
+ GIT_AUTHOR_EMAIL: 'publish@orizu.invalid',
56
+ GIT_COMMITTER_NAME: 'Orizu',
57
+ GIT_COMMITTER_EMAIL: 'publish@orizu.invalid',
58
+ }
59
+
60
+ interface PublishMergeJobEnv {
61
+ bootSecret: string
62
+ jobId: string
63
+ coordinatorUrl: string
64
+ baseUrl: string
65
+ /** The Artifacts git host the coordinator expects; localhost only in tests. */
66
+ expectedGitHost: string
67
+ /** The coordinator's wall, epoch ms; see the timing note above. */
68
+ wallAt?: number
69
+ }
70
+
71
+ export type PublishMergeJobOutcome =
72
+ | { outcome: 'merged'; mergeSha: string }
73
+ | { outcome: 'already_merged'; headSha: string }
74
+ /** At most PUBLISH_MERGE_CONFLICT_FILES_LIMIT paths; `moreFiles` counts the rest. */
75
+ | { outcome: 'conflict'; files: readonly string[]; moreFiles: number }
76
+ | { outcome: 'denied' }
77
+ | { outcome: 'race_lost' }
78
+ | { outcome: 'failed'; code: PublishMergeJobFailureCode }
79
+
80
+ interface Credential {
81
+ tokenId: string
82
+ plaintext: string
83
+ remote: string
84
+ }
85
+
86
+ class JobFailure extends Error {
87
+ constructor(readonly code: PublishMergeJobFailureCode | 'denied') {
88
+ super(code)
89
+ }
90
+ }
91
+
92
+ function resolvePublishMergeJobEnv(env: Record<string, string | undefined>): PublishMergeJobEnv | string {
93
+ const value = (name: string) => env[name]?.trim() ?? ''
94
+ const resolved = {
95
+ bootSecret: value('ORIZU_BOOT_SECRET'),
96
+ jobId: value('ORIZU_PUBLISH_MERGE_JOB_ID'),
97
+ coordinatorUrl: value('ORIZU_COORDINATOR_URL').replace(/\/+$/, ''),
98
+ baseUrl: value('ORIZU_BASE_URL').replace(/\/+$/, ''),
99
+ expectedGitHost: value('ORIZU_PUBLISH_MERGE_GIT_HOST').toLowerCase(),
100
+ }
101
+ if (Object.values(resolved).some((item) => item.length === 0)) return 'missing required env'
102
+ const wallAtText = value(PUBLISH_MERGE_WALL_AT_ENV)
103
+ const wallAt = wallAtText ? Number(wallAtText) : undefined
104
+ if (wallAt !== undefined && (!/^[0-9]{13}$/.test(wallAtText) || !Number.isSafeInteger(wallAt))) return 'invalid wall deadline'
105
+ try {
106
+ assertSecureTokenTransport(resolved.coordinatorUrl)
107
+ assertSecureTokenTransport(resolved.baseUrl)
108
+ } catch {
109
+ return 'insecure coordinator or base URL'
110
+ }
111
+ const localGitAllowed = isLoopbackOrigin(resolved.coordinatorUrl) && isLoopbackOrigin(resolved.baseUrl) && resolved.expectedGitHost === 'localhost'
112
+ if (!UUID_PATTERN.test(resolved.jobId) || (!CLOUDFLARE_ARTIFACTS_HOST_PATTERN.test(resolved.expectedGitHost) && !localGitAllowed)) {
113
+ return 'invalid job id or git host'
114
+ }
115
+ return wallAt === undefined ? resolved : { ...resolved, wallAt }
116
+ }
117
+
118
+ /** Every remote must be an https repository in the hosted namespace on the expected host. */
119
+ function assertRemote(remote: unknown, expectedGitHost: string): string {
120
+ try {
121
+ assertWorkspaceBootstrapRemote(String(remote), expectedGitHost)
122
+ } catch {
123
+ throw new JobFailure('scope_refused')
124
+ }
125
+ return new URL(String(remote)).toString()
126
+ }
127
+
128
+ async function post(url: string, bearer: string, body: object, signal?: AbortSignal): Promise<Record<string, unknown>> {
129
+ let response: Response
130
+ try {
131
+ response = await fetch(url, {
132
+ method: 'POST',
133
+ redirect: 'error',
134
+ signal,
135
+ headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' },
136
+ body: JSON.stringify(body),
137
+ })
138
+ } catch {
139
+ throw new JobFailure(signal?.aborted ? 'job_timed_out' : 'api_unavailable')
140
+ }
141
+ const value: unknown = await response.json().catch(() => null)
142
+ const record = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
143
+ // The session lost its authority while a key was being minted: a refusal.
144
+ if (response.status === 403 && record?.error === 'gate_denied') throw new JobFailure('denied')
145
+ if (response.status === 401 || response.status === 403) throw new JobFailure('scope_refused')
146
+ if (!response.ok || !record) throw new JobFailure('api_unavailable')
147
+ return record
148
+ }
149
+
150
+ /** The permission check's answer, or unavailable for anything else. */
151
+ function gateAllows(value: Record<string, unknown>): boolean {
152
+ const keys = Object.keys(value).sort().join(',')
153
+ if (keys === 'allowed' && value.allowed === true) return true
154
+ if (keys === 'allowed,reason' && value.allowed === false && typeof value.reason === 'string') return false
155
+ throw new JobFailure('api_unavailable')
156
+ }
157
+
158
+ /** ADR-012: the team copy is canonical state, so the merged graph must fit
159
+ * the admission envelope before a write key is asked for. The merge folder
160
+ * holds exactly both copies' fetched objects and the merge commit. */
161
+ async function assertMergeAdmitted(worktree: string, signal: AbortSignal, narrower: Partial<typeof ADMISSION> = {}): Promise<void> {
162
+ const { stdout } = await promisify(execFile)(
163
+ 'git',
164
+ ['cat-file', '--batch-all-objects', '--batch-check=%(objecttype) %(objectsize)'],
165
+ { cwd: worktree, maxBuffer: 64 * 1024 * 1024, signal }
166
+ )
167
+ if (!isMergeInventoryAdmitted(stdout, narrower)) throw new JobFailure('admission_refused')
168
+ }
169
+
170
+ /** One `<type> <size>` line per object, as `git cat-file --batch-check` prints them. */
171
+ export function isMergeInventoryAdmitted(inventory: string, narrower: Partial<typeof ADMISSION> = {}): boolean {
172
+ const limits = Object.fromEntries(
173
+ Object.entries(ADMISSION).map(([key, max]) => [key, Math.min(max, narrower[key as keyof typeof ADMISSION] ?? max)])
174
+ ) as typeof ADMISSION
175
+ let totalBytes = 0
176
+ let objects = 0
177
+ let commits = 0
178
+ for (const line of inventory.split('\n')) {
179
+ if (!line) continue
180
+ const [type, size] = line.split(' ')
181
+ const bytes = Number(size)
182
+ objects += 1
183
+ totalBytes += bytes
184
+ if (type === 'commit') commits += 1
185
+ if (!Number.isSafeInteger(bytes) || bytes > limits.objectBytes) return false
186
+ }
187
+ return totalBytes <= limits.totalBytes && objects <= limits.objects && commits <= limits.commits
188
+ }
189
+
190
+ /** A post with its own time limit. An AbortController and timer, as for the
191
+ * deadline: AbortSignal.timeout did not fire inside the job under bun test. */
192
+ async function postWithin(timeoutMs: number, url: string, bearer: string, body: object): Promise<Record<string, unknown>> {
193
+ const stop = new AbortController()
194
+ const timer = setTimeout(() => stop.abort(), timeoutMs)
195
+ try {
196
+ return await post(url, bearer, body, stop.signal)
197
+ } finally {
198
+ clearTimeout(timer)
199
+ }
200
+ }
201
+
202
+ /** The conflict list the job reports, bounded so the result always fits the
203
+ * coordinator's body limit. A path too long to name is counted instead. */
204
+ export function boundConflictFiles(files: readonly string[]): { files: string[]; moreFiles: number } {
205
+ const named = files.filter((file) => file.length <= PUBLISH_MERGE_CONFLICT_PATH_MAX).slice(0, PUBLISH_MERGE_CONFLICT_FILES_LIMIT)
206
+ return { files: named, moreFiles: files.length - named.length }
207
+ }
208
+
209
+ function toOutcome(result: PublishSessionMergeResult): PublishMergeJobOutcome {
210
+ switch (result.kind) {
211
+ case 'merged':
212
+ return { outcome: 'merged', mergeSha: result.mergeSha }
213
+ case 'already_merged':
214
+ return { outcome: 'already_merged', headSha: result.canonicalSha }
215
+ case 'conflict':
216
+ return { outcome: 'conflict', ...boundConflictFiles(result.files) }
217
+ case 'denied':
218
+ return { outcome: 'denied' }
219
+ case 'race_lost':
220
+ return { outcome: 'race_lost' }
221
+ }
222
+ }
223
+
224
+ export async function runPublishMergeJob(options: {
225
+ env: PublishMergeJobEnv
226
+ deadlineMs?: number
227
+ /** Narrower admission limits; tests only. Never wider than ADMISSION. */
228
+ admission?: Partial<typeof ADMISSION>
229
+ /** Shorter timing; tests only. Each is clamped to its production value. */
230
+ timing?: { resultReserveMs?: number; lateCallMs?: number }
231
+ }): Promise<{ reported: boolean; outcome: PublishMergeJobOutcome }> {
232
+ const { env } = options
233
+ const startedAt = Date.now()
234
+ const wallEnd = Math.min(startedAt + JOB_WALL_MS, env.wallAt ?? Number.POSITIVE_INFINITY)
235
+ const reserveMs = Math.min(options.timing?.resultReserveMs ?? RESULT_RESERVE_MS, RESULT_RESERVE_MS)
236
+ const lateCallMs = Math.min(options.timing?.lateCallMs ?? LATE_CALL_TIMEOUT_MS, LATE_CALL_TIMEOUT_MS)
237
+ const stop = new AbortController()
238
+ const timer = setTimeout(() => stop.abort(), Math.max(0, Math.min(options.deadlineMs ?? JOB_DEADLINE_MS, wallEnd - reserveMs - startedAt)))
239
+ const deadline = stop.signal
240
+ const api = (action: string) => `${env.baseUrl}/api/internal/publish-merge/${env.jobId}/${action}`
241
+ const issued: Array<{ target: 'session' | 'team'; tokenId: string }> = []
242
+ let jobToken = ''
243
+ /** A late call's bound: its own limit, or the time left before the last `keepMs` of the wall. */
244
+ const lateBound = (keepMs: number) => Math.min(lateCallMs, wallEnd - keepMs - Date.now())
245
+ const revokeCall = async (target: 'session' | 'team', tokenId: string) => {
246
+ const bound = lateBound(reserveMs)
247
+ if (bound <= 0) throw new JobFailure('credentials_pending')
248
+ return postWithin(bound, api('revoke'), jobToken, { target, tokenId })
249
+ }
250
+ let outcome: PublishMergeJobOutcome
251
+ const root = await mkdtemp(join(tmpdir(), 'orizu-publish-merge-'))
252
+ try {
253
+ const askPass = await createEphemeralAskPass(root)
254
+ const claimed = await post(`${env.coordinatorUrl}/publish-merge-jobs/${env.jobId}/claim`, env.bootSecret, {}, deadline)
255
+ if (typeof claimed.jobToken !== 'string' || !claimed.jobToken) throw new JobFailure('scope_refused')
256
+ jobToken = claimed.jobToken
257
+ const spec = await post(api('spec'), jobToken, {}, deadline)
258
+ const sessionRemote = assertRemote((spec.session as { remote?: unknown } | undefined)?.remote, env.expectedGitHost)
259
+ if (spec.jobId !== env.jobId || typeof spec.sessionCommitSha !== 'string' || spec.ref !== 'refs/heads/main' || typeof spec.commitMessage !== 'string') {
260
+ throw new JobFailure('scope_refused')
261
+ }
262
+ const mint = async (target: 'session' | 'team', scope: 'read' | 'write'): Promise<Credential> => {
263
+ const value = await post(api('credential'), jobToken, { target, scope }, deadline)
264
+ if (typeof value.tokenId !== 'string' || typeof value.plaintext !== 'string') throw new JobFailure('scope_refused')
265
+ issued.push({ target, tokenId: value.tokenId })
266
+ return { tokenId: value.tokenId, plaintext: value.plaintext, remote: assertRemote(value.remote, env.expectedGitHost) }
267
+ }
268
+ const revoke = async (target: 'session' | 'team', tokenId: string) => {
269
+ const value = await revokeCall(target, tokenId)
270
+ if (value.revoked !== true) throw new JobFailure('credentials_pending')
271
+ issued.splice(issued.findIndex((item) => item.tokenId === tokenId), 1)
272
+ }
273
+ const auth = (credential: Credential): GitAuth => ({ askPassPath: askPass.path, token: credential.plaintext })
274
+
275
+ const sessionRead = await mint('session', 'read')
276
+ if (sessionRead.remote !== sessionRemote) throw new JobFailure('scope_refused')
277
+ const teamRead = await mint('team', 'read')
278
+ const result = await publishSessionMerge({
279
+ root,
280
+ canonical: { remote: teamRead.remote, auth: auth(teamRead) },
281
+ source: { remote: sessionRemote, auth: auth(sessionRead) },
282
+ canonicalRef: 'refs/heads/main',
283
+ sourceRef: 'refs/heads/main',
284
+ expectedSourceSha: spec.sessionCommitSha,
285
+ commitMessage: spec.commitMessage,
286
+ commitIdentityEnv: IDENTITY,
287
+ authorize: async (merge) => {
288
+ await assertMergeAdmitted(merge.worktree, deadline, options.admission)
289
+ return gateAllows(await post(api('gate'), jobToken, {}, deadline))
290
+ },
291
+ withCanonicalWrite: async (push) => {
292
+ const write = await mint('team', 'write')
293
+ if (write.remote !== teamRead.remote) throw new JobFailure('scope_refused')
294
+ try {
295
+ return await push(auth(write))
296
+ } finally {
297
+ await revoke('team', write.tokenId)
298
+ }
299
+ },
300
+ timeoutMs: GIT_TIMEOUT_MS,
301
+ signal: deadline,
302
+ })
303
+ outcome = toOutcome(result)
304
+ } catch (error) {
305
+ if (error instanceof JobFailure && error.code === 'denied') outcome = { outcome: 'denied' }
306
+ else if (deadline.aborted) outcome = { outcome: 'failed', code: 'job_timed_out' }
307
+ else outcome = { outcome: 'failed', code: error instanceof JobFailure && error.code !== 'denied' ? error.code : 'publish_failed' }
308
+ } finally {
309
+ clearTimeout(timer)
310
+ // Revoke whatever is still live, newest first, even past the deadline; an
311
+ // unrevoked key wins over the merge outcome because its authority is then uncertain.
312
+ for (const { target, tokenId } of [...issued].reverse()) {
313
+ try {
314
+ const value = await revokeCall(target, tokenId)
315
+ if (value.revoked !== true) throw new JobFailure('credentials_pending')
316
+ } catch {
317
+ outcome = { outcome: 'failed', code: 'credentials_pending' }
318
+ }
319
+ }
320
+ await rm(root, { recursive: true, force: true })
321
+ }
322
+ let reported = false
323
+ try {
324
+ const bound = lateBound(0)
325
+ if (bound <= 0) throw new JobFailure('job_timed_out')
326
+ await postWithin(bound, `${env.coordinatorUrl}/publish-merge-jobs/${env.jobId}/result`, env.bootSecret, outcome)
327
+ reported = true
328
+ } catch {
329
+ reported = false
330
+ }
331
+ return { reported, outcome }
332
+ }
333
+
334
+ export async function publishMergeJobCommand(
335
+ args: readonly string[],
336
+ io: { print: (line: string) => void; printErr?: (line: string) => void }
337
+ ): Promise<number> {
338
+ if (args.includes('--capability-check')) {
339
+ io.print(PUBLISH_MERGE_JOB_CAPABILITY)
340
+ return 0
341
+ }
342
+ const env = resolvePublishMergeJobEnv(process.env)
343
+ if (typeof env === 'string') {
344
+ io.printErr?.(`publish-merge-job: ${env}`)
345
+ return 1
346
+ }
347
+ const result = await runPublishMergeJob({ env })
348
+ io.print(JSON.stringify(result.outcome))
349
+ return result.reported ? 0 : 1
350
+ }
@@ -0,0 +1,27 @@
1
+ /** ORI-2280: the publish merge job's wall, shared by the coordinator, which
2
+ * stops the sandbox at it, and the CLI job, which must report before it. No
3
+ * Node or Worker imports: both sides compile this file. */
4
+
5
+ /** From the coordinator creating the job to it stopping the sandbox. */
6
+ export const PUBLISH_MERGE_COORDINATOR_WALL_MS = 2 * 60 * 1000
7
+ /** The coordinator sets this on the sandbox to `createdAt + PUBLISH_MERGE_COORDINATOR_WALL_MS`, in epoch milliseconds. */
8
+ export const PUBLISH_MERGE_WALL_AT_ENV = 'ORIZU_PUBLISH_MERGE_WALL_AT'
9
+
10
+ /** A conflict result names at most this many paths, each at most
11
+ * PUBLISH_MERGE_CONFLICT_PATH_MAX characters, and counts the rest, so the
12
+ * result always fits the coordinator's result body limit. */
13
+ export const PUBLISH_MERGE_CONFLICT_FILES_LIMIT = 200
14
+ export const PUBLISH_MERGE_CONFLICT_PATH_MAX = 1024
15
+
16
+ /** The failure codes the sandbox may report. The coordinator's own codes
17
+ * (`timed_out`, `provider_unavailable`, `snapshot_missing_command`, ...) are
18
+ * not among them, so a reported failure always says which side saw it. */
19
+ export const PUBLISH_MERGE_JOB_FAILURE_CODES = [
20
+ 'scope_refused',
21
+ 'admission_refused',
22
+ 'credentials_pending',
23
+ 'api_unavailable',
24
+ 'job_timed_out',
25
+ 'publish_failed',
26
+ ] as const
27
+ export type PublishMergeJobFailureCode = (typeof PUBLISH_MERGE_JOB_FAILURE_CODES)[number]
@@ -45,6 +45,8 @@ export interface WorkbenchRunCosts {
45
45
  export interface WorkbenchRunSummary extends WorkbenchRunCosts {
46
46
  id: string
47
47
  status: string
48
+ /** Why a failed run failed, in plain words, mapped by the server (ORI-2264). */
49
+ failureReason?: string
48
50
  }
49
51
 
50
52
  export interface WorkbenchSession {
@@ -76,6 +78,8 @@ export interface WorkbenchRun extends WorkbenchRunCosts {
76
78
  status: string
77
79
  evidence?: Record<string, unknown>
78
80
  summary?: Record<string, unknown>
81
+ /** Why a failed run failed, in plain words, mapped by the server (ORI-2264). */
82
+ failureReason?: string
79
83
  latestSequence?: number
80
84
  startedAt?: string | null
81
85
  finishedAt?: string | null
@@ -1130,6 +1134,8 @@ function formatSessionStatus(result: SessionStatusResult): string {
1130
1134
  const cost = finiteOrNull(run.modelCostUsd)
1131
1135
  const suffix = cost !== null ? ` ${formatUsd(cost)}` : ''
1132
1136
  lines.push(` run ${run.id} ${run.status}${suffix}`)
1137
+ const why = failedRunLine(run)
1138
+ if (why) lines.push(` ${why}`)
1133
1139
  }
1134
1140
  return lines.join('\n')
1135
1141
  }
@@ -1167,10 +1173,18 @@ function formatSessionFinishOutcome(result: SessionFinishResult): string {
1167
1173
  return `manifest ${id} ${status} repo_merge (${files} files, +${additions}/-${deletions})`
1168
1174
  }
1169
1175
 
1176
+ // The web page's failed line, with the reason the server mapped (ORI-2264).
1177
+ function failedRunLine(run: { status: string; failureReason?: unknown }): string | null {
1178
+ return run.status === 'failed' && typeof run.failureReason === 'string' && run.failureReason
1179
+ ? `The agent stopped: ${run.failureReason}.`
1180
+ : null
1181
+ }
1182
+
1170
1183
  function formatRunStatus(run: WorkbenchRun): string {
1171
1184
  const sequence = typeof run.latestSequence === 'number' ? ` latestSequence=${run.latestSequence}` : ''
1172
1185
  const costLine = formatCostSummary(run)
1173
- return `run ${run.id} ${run.status}${sequence}${costLine ? `\n ${costLine}` : ''}`
1186
+ const why = failedRunLine(run)
1187
+ return `run ${run.id} ${run.status}${sequence}${costLine ? `\n ${costLine}` : ''}${why ? `\n ${why}` : ''}`
1174
1188
  }
1175
1189
 
1176
1190
  // ALI-1045: one compact digest line per event — shared with the attached
@@ -114,7 +114,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
114
114
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
115
115
  }
116
116
 
117
- function isLoopbackOrigin(value: string): boolean {
117
+ export function isLoopbackOrigin(value: string): boolean {
118
118
  try {
119
119
  const host = new URL(value).hostname
120
120
  return host === 'localhost' || host === '127.0.0.1' || host === '::1'
@@ -20,7 +20,7 @@ Compare the inventoried surfaces using source metadata only: availability, prove
20
20
 
21
21
  ## Install and establish the project
22
22
 
23
- After the human chooses Orizu, check whether `orizu` is available and record `orizu --version`. If it is missing, require Node.js 20+ and install the CLI with `npm i -g orizu`. Then run `orizu setup`; verify authentication and runtime with `orizu whoami --json` and `orizu capabilities --json`, and verify the installed skill separately with `orizu skills status --json` (use `orizu skills update` when stale). Use `references/cli-reference.md` for authentication and team/project commands. Follow `references/authority-map.md` for who executes each action. Do not rely on remembered commands.
23
+ After the human chooses Orizu, check that `node --version` is 22 or newer (the CLI requires Node.js 22+), then check whether `orizu` is available and record `orizu --version`. If it is missing, install the CLI with `npm i -g orizu`. Then run `orizu setup`; verify authentication and runtime with `orizu whoami --json` and `orizu capabilities --json`, and verify the installed skill separately with `orizu skills status --json` (use `orizu skills update` when stale). Use `references/cli-reference.md` for authentication and team/project commands. Follow `references/authority-map.md` for who executes each action. Do not rely on remembered commands.
24
24
 
25
25
  ## Ratify the improvement plan
26
26
 
@@ -8,12 +8,12 @@ This is the single source of truth for who runs each action. Replace placeholder
8
8
  | All surfaces | Edit files | Edit and commit source, configs, `App.tsx`, schemas, and local traces under `logs/<run-id>` in the repo working set. | Repo files are not canonical pointer or run state. |
9
9
  | Local and hosted agents | Product feedback filing | File product feedback autonomously with `orizu feedback`, using scrubbed narrative and no customer content. | This bounded feedback command is the agent's own authority; no human approval is required. |
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
- | 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, 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. |
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
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. |
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
- | 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 changes create unpromoted profile heads and do not move pointers. | A human admin or curator with `can_manage_project` runs every profile promote/rollback, instruction-set default move, and other production/default pointer move. |
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. |
17
17
  | Hosted session | Instruction-set visibility | Prepare the requested visibility change. | A human admin or curator with `can_manage_project` runs `orizu instructions archive <slug-or-exact-name> --project <team/project>` or `orizu instructions restore <slug-or-exact-name> --project <team/project>`. Hosted CLI route enforcement returns `Agent sessions cannot perform this action`; direct RPC remains agent-writable. |
18
18
  | Hosted session | Instruction scorers | Run `orizu instructions scorers set-headline <set> --key <component-key> --scorer-version <id> --project <team/project>` and `orizu instructions scorers add <set> --key <component-key> --scorer-version <id> --project <team/project>`; prepare scorer-pointer evidence. | A human admin or curator with `can_manage_project` runs `orizu scorers labels set <scorer-name> production --version <scorer-version-id> --project <team/project>`. The measured 403 is `Agents cannot move scorer labels; labels are human-only pointers. Propose the label change as a promotion manifest for a human to apply.` |
19
19
  | Hosted session | Promotion | After report acceptance, the two-stage path materializes once with unlabeled `orizu optimizations promote <run-id> --candidate <id> --project <team/project>`; hand the exact run id, candidate id, project, and report evidence to the human. | For the staged path, a human admin or curator with `can_manage_project` re-runs the same promotion as `orizu optimizations promote <run-id> --candidate <id> --label production --project <team/project>`. The idempotent finalizer finds the existing version by run and candidate provenance, moves production to that exact materialized version, and creates no duplicate profile version or candidate-promoted event. Equivalent simpler one-shot path: without prior materialization, the human runs the same labeled command once. A human admin or curator also runs `orizu manifests approve <id>`, `orizu manifests reject <id>`, and, after approval, `orizu manifests apply <id>`. |