ultra-builder-pro-cli 0.24.2 → 0.25.0
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/CHANGELOG.md +42 -0
- package/adapters/claude.js +104 -0
- package/adapters/codex.js +363 -40
- package/adapters/grok.js +1254 -58
- package/adapters/kimi.js +682 -13
- package/adapters/opencode.js +132 -11
- package/docs/AGENT-CONTEXT.md +14 -0
- package/docs/ARTIFACT-AUTHORITY.md +13 -0
- package/docs/COMMIT-HASH-BACKFILL.md +25 -18
- package/docs/RUNTIME-COMPAT-MATRIX.md +18 -2
- package/mcp-server/lib/artifact-registry.cjs +181 -51
- package/mcp-server/lib/baseline-workflow.cjs +160 -23
- package/mcp-server/lib/change-workflow.cjs +21 -16
- package/mcp-server/lib/context-envelope.cjs +124 -51
- package/mcp-server/lib/decision-records.cjs +164 -78
- package/mcp-server/lib/kernel-change-workflow.cjs +2029 -0
- package/mcp-server/lib/legacy-change-workflow.cjs +294 -71
- package/mcp-server/lib/managed-file-write.cjs +56 -0
- package/mcp-server/lib/prd-parser.cjs +2 -2
- package/mcp-server/lib/stage-checkpoints.cjs +163 -9
- package/mcp-server/lib/state-db.cjs +186 -8
- package/mcp-server/lib/state-ops.cjs +214 -31
- package/mcp-server/lib/task-contract.cjs +404 -0
- package/mcp-server/lib/task-expander.cjs +2 -2
- package/mcp-server/lib/task-ledger.cjs +175 -62
- package/mcp-server/lib/ultra-facade.cjs +1786 -169
- package/mcp-server/lib/worker-packet.cjs +2 -2
- package/mcp-server/server.cjs +23 -4
- package/orchestrator/parallel-orchestrator.cjs +114 -27
- package/orchestrator/session-runner.cjs +25 -136
- package/package.json +1 -1
- package/skills/ultra-change/SKILL.md +6 -1
- package/skills/ultra-dev/SKILL.md +9 -4
- package/skills/ultra-plan/SKILL.md +13 -4
- package/skills/ultra-plan/references/semantic-preflight.md +6 -4
- package/spec/mcp-tools.yaml +114 -9
- package/spec/schemas/context-file.v4.5.schema.json +2 -2
- package/spec/schemas/state-db.sql +10 -6
- package/spec/schemas/task-ledger.v2.schema.json +23 -8
- package/spec/schemas/tasks.v4.5.schema.json +34 -12
package/adapters/opencode.js
CHANGED
|
@@ -6,6 +6,8 @@ const fs = require('node:fs');
|
|
|
6
6
|
const crypto = require('node:crypto');
|
|
7
7
|
const os = require('node:os');
|
|
8
8
|
const path = require('node:path');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
9
11
|
|
|
10
12
|
const {
|
|
11
13
|
copyTree,
|
|
@@ -64,6 +66,79 @@ function resolveRepoRoot(ctx = {}) {
|
|
|
64
66
|
return ctx.repoRoot || path.resolve(__dirname, '..');
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
function shouldRunHostCli(ctx = {}) {
|
|
70
|
+
if (typeof ctx.runHostCli === 'boolean') return ctx.runHostCli;
|
|
71
|
+
return ctx.scope === 'global' && !ctx.configDir;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runOpenCodeCli(args, ctx = {}) {
|
|
75
|
+
const target = resolveTarget(ctx);
|
|
76
|
+
const result = spawnSync(ctx.opencodeBin || 'opencode', args, {
|
|
77
|
+
cwd: ctx.cwd || process.cwd(),
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
killSignal: 'SIGKILL',
|
|
81
|
+
env: {
|
|
82
|
+
...process.env,
|
|
83
|
+
OPENCODE_CONFIG_DIR: target,
|
|
84
|
+
},
|
|
85
|
+
timeout: ctx.hostCliTimeoutMs || 30000,
|
|
86
|
+
});
|
|
87
|
+
if (result.error) {
|
|
88
|
+
if (result.error.code === 'ETIMEDOUT') {
|
|
89
|
+
const error = new Error(`opencode ${args.join(' ')} timed out`);
|
|
90
|
+
error.code = 'HOST_CLI_TIMEOUT';
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
throw result.error;
|
|
94
|
+
}
|
|
95
|
+
if (result.status !== 0) {
|
|
96
|
+
const detail = (result.stderr || result.stdout || '').trim();
|
|
97
|
+
throw new Error(`opencode ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
|
|
98
|
+
}
|
|
99
|
+
return result.stdout;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function openCodeMcpRow(output, serverName) {
|
|
103
|
+
for (const rawLine of output.split(/\r?\n/)) {
|
|
104
|
+
const tokens = rawLine.trim().split(/\s+/);
|
|
105
|
+
if (tokens.some((token) => token === serverName)) return rawLine;
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function inspectOpenCodeHost(ctx = {}) {
|
|
111
|
+
const target = resolveTarget(ctx);
|
|
112
|
+
const expectedLauncher = path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs');
|
|
113
|
+
const expected = managedMcpEntry(target);
|
|
114
|
+
const config = JSON.parse(runOpenCodeCli(['debug', 'config'], ctx));
|
|
115
|
+
const entry = config?.mcp?.[MCP_SERVER_NAME];
|
|
116
|
+
const configOk = isDeepStrictEqual(entry, expected);
|
|
117
|
+
const mcpOutput = runOpenCodeCli(['mcp', 'list'], ctx)
|
|
118
|
+
.replace(/\u001b\[[0-9;]*m/g, '');
|
|
119
|
+
const mcpLine = openCodeMcpRow(mcpOutput, MCP_SERVER_NAME);
|
|
120
|
+
const mcpOk = /\bconnected\b/i.test(mcpLine);
|
|
121
|
+
return { config, configOk, mcpOutput, mcpOk };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function managedMcpEntry(target) {
|
|
125
|
+
const command = mcpCommand(path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs'));
|
|
126
|
+
return {
|
|
127
|
+
type: 'local',
|
|
128
|
+
enabled: true,
|
|
129
|
+
command: [command.command, ...command.args],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function hasManagedMcpIdentity(entry, target) {
|
|
134
|
+
return Boolean(
|
|
135
|
+
entry
|
|
136
|
+
&& typeof entry === 'object'
|
|
137
|
+
&& !Array.isArray(entry)
|
|
138
|
+
&& isDeepStrictEqual(entry.command, managedMcpEntry(target).command),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
67
142
|
function lowercaseFrontmatterTransform(buf, relPath) {
|
|
68
143
|
if (!relPath.endsWith('.md')) return buf;
|
|
69
144
|
const text = buf.toString('utf8');
|
|
@@ -550,12 +625,7 @@ function buildStaging(repoRoot, staging, target) {
|
|
|
550
625
|
const configFile = path.join(target, 'opencode.json');
|
|
551
626
|
const existing = readJsonSafe(configFile, { rescue: true });
|
|
552
627
|
const mcp = { ...(existing.mcp || {}) };
|
|
553
|
-
|
|
554
|
-
mcp[MCP_SERVER_NAME] = {
|
|
555
|
-
type: 'local',
|
|
556
|
-
enabled: true,
|
|
557
|
-
command: [command.command, ...command.args],
|
|
558
|
-
};
|
|
628
|
+
mcp[MCP_SERVER_NAME] = managedMcpEntry(target);
|
|
559
629
|
const next = { ...existing, mcp };
|
|
560
630
|
delete next[LEGACY_SENTINEL_KEY];
|
|
561
631
|
writeAtomic(path.join(staging, 'opencode.json'), JSON.stringify(next, null, 2) + '\n');
|
|
@@ -720,12 +790,45 @@ function doctor(ctx = {}) {
|
|
|
720
790
|
});
|
|
721
791
|
}
|
|
722
792
|
report.checks.registration = { status: registrationOk ? 'pass' : 'fail' };
|
|
793
|
+
if (shouldRunHostCli(ctx)) {
|
|
794
|
+
try {
|
|
795
|
+
const host = inspectOpenCodeHost(ctx);
|
|
796
|
+
report.checks.host_config = { status: host.configOk ? 'pass' : 'fail' };
|
|
797
|
+
report.checks.host_mcp = { status: host.mcpOk ? 'pass' : 'fail' };
|
|
798
|
+
if (!host.configOk) {
|
|
799
|
+
report.issues.push({
|
|
800
|
+
code: 'HOST_CONFIG_NOT_CONSUMED',
|
|
801
|
+
path: configFile,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
if (!host.mcpOk) {
|
|
805
|
+
report.issues.push({
|
|
806
|
+
code: 'HOST_MCP_NOT_CONNECTED',
|
|
807
|
+
server: MCP_SERVER_NAME,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
} catch (error) {
|
|
811
|
+
report.checks.host_config = { status: 'fail' };
|
|
812
|
+
report.checks.host_mcp = { status: 'fail' };
|
|
813
|
+
report.issues.push({
|
|
814
|
+
code: error.code === 'HOST_CLI_TIMEOUT'
|
|
815
|
+
? 'HOST_CLI_TIMEOUT'
|
|
816
|
+
: 'HOST_CLI_INSPECTION_FAILED',
|
|
817
|
+
message: error.message,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
723
821
|
return applyNativeDoctor(report, path.join(target, BUNDLE_DIR, 'runtime'));
|
|
724
822
|
}
|
|
725
823
|
|
|
726
824
|
function uninstall(ctx = {}) {
|
|
727
825
|
const target = resolveTarget(ctx);
|
|
728
|
-
const report = {
|
|
826
|
+
const report = {
|
|
827
|
+
target,
|
|
828
|
+
removed: {},
|
|
829
|
+
config: { updated: false },
|
|
830
|
+
issues: [],
|
|
831
|
+
};
|
|
729
832
|
const pluginFile = path.join(target, 'plugins', 'ultra-builder-pro.js');
|
|
730
833
|
const bundleRoot = path.join(target, BUNDLE_DIR);
|
|
731
834
|
const pluginOwned = fs.existsSync(pluginFile)
|
|
@@ -734,14 +837,31 @@ function uninstall(ctx = {}) {
|
|
|
734
837
|
const configFile = path.join(target, 'opencode.json');
|
|
735
838
|
if (fs.existsSync(configFile)) {
|
|
736
839
|
const existing = readJsonSafe(configFile);
|
|
737
|
-
|
|
840
|
+
const currentMcp = existing.mcp?.[MCP_SERVER_NAME];
|
|
841
|
+
const expectedMcp = managedMcpEntry(target);
|
|
842
|
+
const mcpOwned = hasManagedMcpIdentity(currentMcp, target);
|
|
843
|
+
if (currentMcp !== undefined && !mcpOwned) {
|
|
844
|
+
report.config.skipped = true;
|
|
845
|
+
report.config.reason = 'ownership-conflict';
|
|
846
|
+
report.issues.push({
|
|
847
|
+
code: 'MCP_OWNERSHIP_CONFLICT',
|
|
848
|
+
path: configFile,
|
|
849
|
+
server: MCP_SERVER_NAME,
|
|
850
|
+
expected: expectedMcp,
|
|
851
|
+
actual: currentMcp,
|
|
852
|
+
});
|
|
853
|
+
return report;
|
|
854
|
+
}
|
|
855
|
+
if (mcpOwned || existing[LEGACY_SENTINEL_KEY]) {
|
|
738
856
|
const mcp = { ...(existing.mcp || {}) };
|
|
739
|
-
delete mcp[MCP_SERVER_NAME];
|
|
857
|
+
if (mcpOwned) delete mcp[MCP_SERVER_NAME];
|
|
740
858
|
const next = { ...existing, mcp };
|
|
741
859
|
delete next[LEGACY_SENTINEL_KEY];
|
|
742
860
|
if (Object.keys(next.mcp || {}).length === 0) delete next.mcp;
|
|
743
|
-
|
|
744
|
-
|
|
861
|
+
if (!isDeepStrictEqual(next, existing)) {
|
|
862
|
+
writeAtomic(configFile, JSON.stringify(next, null, 2) + '\n');
|
|
863
|
+
report.config.updated = true;
|
|
864
|
+
}
|
|
745
865
|
}
|
|
746
866
|
}
|
|
747
867
|
if (pluginOwned) {
|
|
@@ -792,6 +912,7 @@ module.exports = {
|
|
|
792
912
|
SOURCE_TAG,
|
|
793
913
|
BUNDLE_DIR,
|
|
794
914
|
pluginSource,
|
|
915
|
+
inspectOpenCodeHost,
|
|
795
916
|
resolveTarget,
|
|
796
917
|
install,
|
|
797
918
|
uninstall,
|
package/docs/AGENT-CONTEXT.md
CHANGED
|
@@ -58,6 +58,20 @@ digest. It never creates the rejected semantic row or claims that authority was
|
|
|
58
58
|
committed. `_ultra.projection_commit` reports only the post-call generated-view cycle;
|
|
59
59
|
it is not a semantic acceptance receipt.
|
|
60
60
|
|
|
61
|
+
Every public input has one exact kind/action contract. Unknown fields, wrong scalar or
|
|
62
|
+
container types, invalid enums, malformed digests, and mismatched scopes are diagnosed
|
|
63
|
+
before SQLite affinity, filesystem writers, or host adapters can coerce them. A
|
|
64
|
+
correctable rejection returns `accepted: false`, `mutable: true`, typed diagnostics,
|
|
65
|
+
and bounded audit history; it is not an MCP transport failure. Reusing an idempotency
|
|
66
|
+
key for a different request follows that same repairable path, while retrying the exact
|
|
67
|
+
request returns the original result.
|
|
68
|
+
|
|
69
|
+
Every accepted mutation and its idempotency receipt are one logical commit. DB-only
|
|
70
|
+
writes share one immediate transaction. A write that also owns a managed file, team
|
|
71
|
+
checkpoint, worktree, Worker Packet, or archive journal either publishes both sides or
|
|
72
|
+
restores the prior bytes and rows. If compensation itself cannot be proven, the kernel
|
|
73
|
+
returns a typed recovery requirement instead of reporting partial success.
|
|
74
|
+
|
|
61
75
|
## Context Envelope
|
|
62
76
|
|
|
63
77
|
Every Skill begins by reading `ultra.context` for the relevant stage and scope. The
|
|
@@ -88,6 +88,19 @@ durable effects, typed applied references, Stage Checkpoint evidence, artifact h
|
|
|
88
88
|
verification commands and results, provenance, events, and recovery state. It must not
|
|
89
89
|
include chain-of-thought, UI-click proof, raw prompts, or conversation transcripts.
|
|
90
90
|
|
|
91
|
+
Public kernel inputs are exact at their owning kind/action boundary. SQLite constraints
|
|
92
|
+
remain defense in depth, not the first definition of a business vocabulary: wrong
|
|
93
|
+
types, unsupported enums, unknown fields, malformed references, and invalid digests
|
|
94
|
+
are rejected as typed mutable diagnostics before any row or file write. The rejected
|
|
95
|
+
attempt is retained as audit evidence but never becomes semantic authority.
|
|
96
|
+
|
|
97
|
+
An accepted mutation and its durable idempotency receipt form one logical transaction.
|
|
98
|
+
DB-only operations commit them together. Operations that also publish managed files,
|
|
99
|
+
the Git team checkpoint, a Session worktree/Worker Packet, or an archive packet use a
|
|
100
|
+
recoverable journal or verified compensation. Failure restores the exact prior file
|
|
101
|
+
bytes, rows, ledger generation, lease, packet, and worktree where applicable; an
|
|
102
|
+
unprovable rollback becomes an explicit recovery condition rather than false success.
|
|
103
|
+
|
|
91
104
|
Verified conclusions from `.ultra/.runtime/collab/` or other scratch locations must be promoted
|
|
92
105
|
into the invoking DB-bound workflow artifact or report. Keeping a file in the scratch
|
|
93
106
|
directory is not promotion.
|
|
@@ -11,27 +11,34 @@ longer changes any tracked file.
|
|
|
11
11
|
|
|
12
12
|
After implementation, verification, and review are current:
|
|
13
13
|
|
|
14
|
-
1.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
3.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
1. Record `task_outcome / complete { id, packet_digest }`. Completion validates the
|
|
15
|
+
assigned Worker Packet and durable output, and accepts neither an arbitrary `patch`
|
|
16
|
+
nor any commit hash.
|
|
17
|
+
2. Publish the completed durable status and Task contract with `ultra.sync`.
|
|
18
|
+
3. Create one local integration commit containing the implementation, semantic Ultra
|
|
19
|
+
artifacts, and `.ultra/tasks/tasks.json`.
|
|
20
|
+
4. Read the resulting full `HEAD` and record
|
|
21
|
+
`task_outcome / attest_commit { id, completion_commit }`.
|
|
22
|
+
5. Verify the task worktree head, integration ancestry, review evidence, and public
|
|
23
|
+
seam; then release the session.
|
|
24
|
+
|
|
25
|
+
Attestation requires an already completed Task and a real commit integrated into the
|
|
26
|
+
current checkout. It changes only ignored runtime state, does not republish team
|
|
27
|
+
authority, and is idempotent for the same hash. Git remains clean, so no bookkeeping
|
|
28
|
+
commit is permitted.
|
|
29
|
+
|
|
30
|
+
If sync or the integration commit fails after completion, record
|
|
31
|
+
`task_outcome / block { id }`, preserve the still-session-owned worktree, and record the
|
|
32
|
+
failure. This recovery transition is accepted only while `completion_commit` is absent.
|
|
33
|
+
An attested Task cannot use this block recovery; an explicit later reopen records the
|
|
34
|
+
prior attestation in event history before clearing the current local proof.
|
|
29
35
|
|
|
30
36
|
## Parent-supervised workers
|
|
31
37
|
|
|
32
|
-
A worker records evidence and exits without finalizing the parent-owned
|
|
33
|
-
primary host
|
|
34
|
-
checkpoint,
|
|
38
|
+
A worker records evidence and exits without finalizing the parent-owned Task. The
|
|
39
|
+
primary host validates and stages the worker result, records durable completion,
|
|
40
|
+
publishes the team checkpoint, creates the single authoritative integration commit,
|
|
41
|
+
attests that integrated hash locally, and settles the lease.
|
|
35
42
|
|
|
36
43
|
## Why the hash is local
|
|
37
44
|
|
|
@@ -106,6 +106,8 @@ instructions, project `.ultra`, unrelated plugins, and unrelated host configurat
|
|
|
106
106
|
| Global flag | `--claude` | `--opencode` | `--codex` | `--kimi` | `--grok` |
|
|
107
107
|
| Staging + atomic swap | FULL | FULL | FULL | FULL | FULL |
|
|
108
108
|
| Exact-host preflight | FULL | FULL | FULL | FULL | FULL |
|
|
109
|
+
| Native registration truth | host manifest | host config | plugin manifest/cache | installed registry | `grok plugin list/details` |
|
|
110
|
+
| Active MCP truth | host MCP config | host MCP config | plugin cache | managed plugin config | `grok inspect` + `grok mcp doctor` |
|
|
109
111
|
| Managed uninstall guard | FULL | FULL | FULL | FULL | FULL |
|
|
110
112
|
| Preserve unrelated config | FULL | FULL | FULL | FULL | FULL |
|
|
111
113
|
| Read-only Doctor | FULL | FULL | FULL | FULL | FULL |
|
|
@@ -114,6 +116,20 @@ Preflight uses the final manifest command and verifies initialize, `tools/list =
|
|
|
114
116
|
public write/read, Doctor backup, close/reopen consistency, native provenance, and
|
|
115
117
|
lazy project initialization.
|
|
116
118
|
|
|
119
|
+
The Grok adapter probes the installed CLI's command and flag capabilities rather
|
|
120
|
+
than branching on a Grok version number. Global installation uses Grok's native
|
|
121
|
+
plugin registry, keeps a durable managed local source for native refresh, and
|
|
122
|
+
resolves the MCP launcher through `${GROK_PLUGIN_ROOT}` after Grok copies the plugin
|
|
123
|
+
under its dynamic `installed-plugins/<repo-key>` path. An older raw
|
|
124
|
+
`$GROK_HOME/plugins/ultra-builder-pro` installation is backed up and migrated; a
|
|
125
|
+
failed native install restores the prior source and registration.
|
|
126
|
+
|
|
127
|
+
`grok mcp list` is not the plugin-MCP inventory surface in current Grok releases;
|
|
128
|
+
it reports explicit user/project MCP configuration. Ultra therefore does not add a
|
|
129
|
+
second `[mcp_servers]` entry merely to make that command display the plugin. The
|
|
130
|
+
authoritative plugin consumer checks are the native plugin registry, merged
|
|
131
|
+
`grok inspect` MCP source, and the live `grok mcp doctor` handshake.
|
|
132
|
+
|
|
117
133
|
## Durable compatibility
|
|
118
134
|
|
|
119
135
|
| Capability | Contract |
|
|
@@ -138,5 +154,5 @@ are reconstructed only from verified authority.
|
|
|
138
154
|
preservation.
|
|
139
155
|
- `tests/package-smoke.test.cjs` installs the npm tarball and performs five real
|
|
140
156
|
launcher write/read/backup/reopen round trips.
|
|
141
|
-
- Grok acceptance
|
|
142
|
-
camelCase Hook fixtures.
|
|
157
|
+
- Grok acceptance runs capability probing, native install/list/details/enable,
|
|
158
|
+
merged inspect, MCP Doctor, migration rollback, and camelCase Hook fixtures.
|
|
@@ -17,6 +17,12 @@ const ENDPOINT_TYPES = new Set([
|
|
|
17
17
|
]);
|
|
18
18
|
const OWNER_TYPES = new Set(['project', 'baseline', 'change', 'task', 'workflow']);
|
|
19
19
|
const STATUSES = new Set(['current', 'stale', 'terminal', 'archived']);
|
|
20
|
+
const ARTIFACT_INPUT_FIELDS = new Set([
|
|
21
|
+
'id', 'owner_type', 'owner_id', 'change_id', 'task_id', 'kind', 'path',
|
|
22
|
+
'content_digest', 'expected_before_digest', 'source_refs', 'consumer_refs',
|
|
23
|
+
'provenance', 'metadata', 'status',
|
|
24
|
+
]);
|
|
25
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
20
26
|
const OWNER_TABLES = Object.freeze({
|
|
21
27
|
baseline: 'baselines',
|
|
22
28
|
change: 'changes',
|
|
@@ -48,6 +54,70 @@ class ArtifactRegistryError extends Error {
|
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
function artifactText(value, field, { optional = false, nullable = false } = {}) {
|
|
58
|
+
if (value === undefined && optional) return undefined;
|
|
59
|
+
if (value === null && nullable) return null;
|
|
60
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
61
|
+
throw new ArtifactRegistryError(
|
|
62
|
+
'VALIDATION_ERROR',
|
|
63
|
+
`${field} must be a non-empty string${nullable ? ' or null' : ''}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return value.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function artifactDigest(value, field, { nullable = false } = {}) {
|
|
70
|
+
if (value === undefined) return undefined;
|
|
71
|
+
if (value === null && nullable) return null;
|
|
72
|
+
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
|
73
|
+
throw new ArtifactRegistryError(
|
|
74
|
+
'VALIDATION_ERROR',
|
|
75
|
+
`${field} must be a lowercase SHA-256 digest${nullable ? ' or null' : ''}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertJsonValue(value, field, seen = new Set()) {
|
|
82
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
83
|
+
if (typeof value === 'number') {
|
|
84
|
+
if (Number.isFinite(value)) return;
|
|
85
|
+
throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof value !== 'object') {
|
|
88
|
+
throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
|
|
89
|
+
}
|
|
90
|
+
if (seen.has(value)) {
|
|
91
|
+
throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must not contain circular references`);
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(value)) {
|
|
94
|
+
const prototype = Object.getPrototypeOf(value);
|
|
95
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
96
|
+
throw new ArtifactRegistryError(
|
|
97
|
+
'VALIDATION_ERROR', `${field} must contain only JSON objects`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
seen.add(value);
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
value.forEach((item, index) => assertJsonValue(item, `${field}[${index}]`, seen));
|
|
104
|
+
} else {
|
|
105
|
+
Object.entries(value).forEach(([key, item]) => (
|
|
106
|
+
assertJsonValue(item, `${field}.${key}`, seen)
|
|
107
|
+
));
|
|
108
|
+
}
|
|
109
|
+
seen.delete(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeJsonObject(value, field) {
|
|
113
|
+
if (value === undefined) return {};
|
|
114
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
115
|
+
throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must be a JSON object`);
|
|
116
|
+
}
|
|
117
|
+
assertJsonValue(value, field);
|
|
118
|
+
return JSON.parse(JSON.stringify(value));
|
|
119
|
+
}
|
|
120
|
+
|
|
51
121
|
function parseJson(value, fallback) {
|
|
52
122
|
if (value == null || value === '') return fallback;
|
|
53
123
|
try { return JSON.parse(value); } catch { return fallback; }
|
|
@@ -302,6 +372,13 @@ function normalizeEndpoint(endpoint, field) {
|
|
|
302
372
|
if (!endpoint || typeof endpoint !== 'object' || Array.isArray(endpoint)) {
|
|
303
373
|
throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must be an object`);
|
|
304
374
|
}
|
|
375
|
+
const unknown = Object.keys(endpoint)
|
|
376
|
+
.filter((key) => !['type', 'id', 'relation'].includes(key));
|
|
377
|
+
if (unknown.length > 0) {
|
|
378
|
+
throw new ArtifactRegistryError(
|
|
379
|
+
'VALIDATION_ERROR', `${field}.${unknown[0]} is not allowed`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
305
382
|
const type = typeof endpoint.type === 'string' ? endpoint.type.trim() : '';
|
|
306
383
|
const id = typeof endpoint.id === 'string' ? endpoint.id.trim() : '';
|
|
307
384
|
const relation = typeof endpoint.relation === 'string' ? endpoint.relation.trim() : '';
|
|
@@ -313,6 +390,59 @@ function normalizeEndpoint(endpoint, field) {
|
|
|
313
390
|
return { type, id, relation };
|
|
314
391
|
}
|
|
315
392
|
|
|
393
|
+
function normalizeArtifactInput(input) {
|
|
394
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
395
|
+
throw new ArtifactRegistryError('VALIDATION_ERROR', 'artifact input is required');
|
|
396
|
+
}
|
|
397
|
+
const unknown = Object.keys(input).filter((field) => !ARTIFACT_INPUT_FIELDS.has(field));
|
|
398
|
+
if (unknown.length > 0) {
|
|
399
|
+
throw new ArtifactRegistryError(
|
|
400
|
+
'VALIDATION_ERROR', `artifact.${unknown[0]} is not allowed`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
const status = input.status === undefined
|
|
404
|
+
? 'current'
|
|
405
|
+
: artifactText(input.status, 'artifact.status');
|
|
406
|
+
if (!STATUSES.has(status)) {
|
|
407
|
+
throw new ArtifactRegistryError('VALIDATION_ERROR', `invalid artifact status: ${status}`);
|
|
408
|
+
}
|
|
409
|
+
const normalizeRefs = (value, field) => {
|
|
410
|
+
if (value === undefined) return [];
|
|
411
|
+
if (!Array.isArray(value)) {
|
|
412
|
+
throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must be an array`);
|
|
413
|
+
}
|
|
414
|
+
return value.map((item, index) => normalizeEndpoint(item, `${field}[${index}]`));
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
id: artifactText(input.id, 'artifact.id', { optional: true }),
|
|
418
|
+
ownerType: artifactText(input.owner_type, 'artifact.owner_type'),
|
|
419
|
+
ownerId: artifactText(input.owner_id, 'artifact.owner_id'),
|
|
420
|
+
changeId: artifactText(
|
|
421
|
+
input.change_id,
|
|
422
|
+
'artifact.change_id',
|
|
423
|
+
{ optional: true, nullable: true },
|
|
424
|
+
),
|
|
425
|
+
taskId: artifactText(
|
|
426
|
+
input.task_id,
|
|
427
|
+
'artifact.task_id',
|
|
428
|
+
{ optional: true, nullable: true },
|
|
429
|
+
),
|
|
430
|
+
kind: artifactText(input.kind, 'artifact.kind'),
|
|
431
|
+
path: artifactText(input.path, 'artifact.path'),
|
|
432
|
+
status,
|
|
433
|
+
contentDigest: artifactDigest(input.content_digest, 'artifact.content_digest'),
|
|
434
|
+
expectedBeforeDigest: artifactDigest(
|
|
435
|
+
input.expected_before_digest,
|
|
436
|
+
'artifact.expected_before_digest',
|
|
437
|
+
{ nullable: true },
|
|
438
|
+
),
|
|
439
|
+
sourceRefs: normalizeRefs(input.source_refs, 'artifact.source_refs'),
|
|
440
|
+
consumerRefs: normalizeRefs(input.consumer_refs, 'artifact.consumer_refs'),
|
|
441
|
+
provenance: normalizeJsonObject(input.provenance, 'artifact.provenance'),
|
|
442
|
+
metadata: normalizeJsonObject(input.metadata, 'artifact.metadata'),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
316
446
|
function assertEndpoint(db, endpoint, field) {
|
|
317
447
|
const normalized = normalizeEndpoint(endpoint, field);
|
|
318
448
|
const { type, id } = normalized;
|
|
@@ -339,6 +469,34 @@ function assertOwner(db, ownerType, ownerId) {
|
|
|
339
469
|
}
|
|
340
470
|
}
|
|
341
471
|
|
|
472
|
+
function assertArtifactOwner(db, ownerType, ownerId) {
|
|
473
|
+
assertOwner(db, ownerType, ownerId);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function assertDeclaredOwnerRefs(db, normalized) {
|
|
477
|
+
const expectedTaskId = normalized.ownerType === 'task' ? normalized.ownerId : null;
|
|
478
|
+
const expectedChangeId = normalized.ownerType === 'change'
|
|
479
|
+
? normalized.ownerId
|
|
480
|
+
: normalized.ownerType === 'task'
|
|
481
|
+
? db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(normalized.ownerId)?.change_id || null
|
|
482
|
+
: normalized.ownerType === 'workflow'
|
|
483
|
+
? db.prepare('SELECT change_id FROM workflow_runs WHERE id = ?').get(normalized.ownerId)?.change_id || null
|
|
484
|
+
: null;
|
|
485
|
+
for (const [field, supplied, expected] of [
|
|
486
|
+
['change_id', normalized.changeId, expectedChangeId],
|
|
487
|
+
['task_id', normalized.taskId, expectedTaskId],
|
|
488
|
+
]) {
|
|
489
|
+
if (supplied !== undefined && supplied !== expected) {
|
|
490
|
+
throw new ArtifactRegistryError(
|
|
491
|
+
'ARTIFACT_AUTHORITY_CONFLICT',
|
|
492
|
+
`artifact ${field} does not match its owner authority`,
|
|
493
|
+
{ field, supplied, expected },
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return { changeId: expectedChangeId, taskId: expectedTaskId };
|
|
498
|
+
}
|
|
499
|
+
|
|
342
500
|
function edgesForArtifact(db, artifactId) {
|
|
343
501
|
const sources = db.prepare(
|
|
344
502
|
`SELECT source_type AS type, source_id AS id, relation
|
|
@@ -527,28 +685,20 @@ function invalidateDownstreamInTx(db, artifactId, priorConsumers = []) {
|
|
|
527
685
|
}
|
|
528
686
|
|
|
529
687
|
function preflightArtifactPublication(db, input, { rootDir = process.cwd() } = {}) {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const ownerId = typeof input.owner_id === 'string' ? input.owner_id.trim() : '';
|
|
535
|
-
const kind = typeof input.kind === 'string' ? input.kind.trim() : '';
|
|
536
|
-
const status = input.status || 'current';
|
|
537
|
-
if (!kind || !STATUSES.has(status)) {
|
|
538
|
-
throw new ArtifactRegistryError(
|
|
539
|
-
'VALIDATION_ERROR', 'artifact kind and valid status are required',
|
|
540
|
-
);
|
|
541
|
-
}
|
|
688
|
+
const normalized = normalizeArtifactInput(input);
|
|
689
|
+
const {
|
|
690
|
+
ownerType, ownerId, kind, status,
|
|
691
|
+
} = normalized;
|
|
542
692
|
assertOwner(db, ownerType, ownerId);
|
|
543
|
-
|
|
693
|
+
assertDeclaredOwnerRefs(db, normalized);
|
|
694
|
+
const resolved = resolveArtifactFile(rootDir, normalized.path);
|
|
544
695
|
if (isExemptArtifactPath(resolved.relative) || isGeneratedProjectionPath(resolved.relative)) {
|
|
545
696
|
throw new ArtifactRegistryError(
|
|
546
697
|
'ARTIFACT_PATH_EXEMPT',
|
|
547
698
|
`runtime, scratch, and generated projection paths are not registry authority: ${resolved.relative}`,
|
|
548
699
|
);
|
|
549
700
|
}
|
|
550
|
-
const explicitId =
|
|
551
|
-
? input.id.trim() : null;
|
|
701
|
+
const explicitId = normalized.id || null;
|
|
552
702
|
const existingById = explicitId
|
|
553
703
|
? db.prepare('SELECT * FROM artifacts WHERE id = ?').get(explicitId)
|
|
554
704
|
: null;
|
|
@@ -625,18 +775,14 @@ function preflightArtifactPublication(db, input, { rootDir = process.cwd() } = {
|
|
|
625
775
|
}
|
|
626
776
|
|
|
627
777
|
function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const kind = typeof input.kind === 'string' ? input.kind.trim() : '';
|
|
634
|
-
const status = input.status || 'current';
|
|
635
|
-
if (!kind || !STATUSES.has(status)) {
|
|
636
|
-
throw new ArtifactRegistryError('VALIDATION_ERROR', 'artifact kind and valid status are required');
|
|
637
|
-
}
|
|
778
|
+
const normalized = normalizeArtifactInput(input);
|
|
779
|
+
const {
|
|
780
|
+
ownerType, ownerId, kind, status,
|
|
781
|
+
sourceRefs, consumerRefs, provenance, metadata,
|
|
782
|
+
} = normalized;
|
|
638
783
|
assertOwner(db, ownerType, ownerId);
|
|
639
|
-
const
|
|
784
|
+
const ownerRefs = assertDeclaredOwnerRefs(db, normalized);
|
|
785
|
+
const resolved = resolveArtifactFile(rootDir, normalized.path);
|
|
640
786
|
if (isExemptArtifactPath(resolved.relative) || isGeneratedProjectionPath(resolved.relative)) {
|
|
641
787
|
throw new ArtifactRegistryError(
|
|
642
788
|
'ARTIFACT_PATH_EXEMPT',
|
|
@@ -645,18 +791,7 @@ function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
645
791
|
}
|
|
646
792
|
const stableRead = openStableArtifactRead(resolved);
|
|
647
793
|
try {
|
|
648
|
-
const
|
|
649
|
-
? input.source_refs.map((item, index) => normalizeEndpoint(item, `source_refs[${index}]`))
|
|
650
|
-
: [];
|
|
651
|
-
const consumerRefs = Array.isArray(input.consumer_refs)
|
|
652
|
-
? input.consumer_refs.map((item, index) => normalizeEndpoint(item, `consumer_refs[${index}]`))
|
|
653
|
-
: [];
|
|
654
|
-
const provenance = input.provenance && typeof input.provenance === 'object'
|
|
655
|
-
&& !Array.isArray(input.provenance) ? input.provenance : {};
|
|
656
|
-
const metadata = input.metadata && typeof input.metadata === 'object'
|
|
657
|
-
&& !Array.isArray(input.metadata) ? input.metadata : {};
|
|
658
|
-
const explicitId = typeof input.id === 'string' && input.id.trim() !== ''
|
|
659
|
-
? input.id.trim() : null;
|
|
794
|
+
const explicitId = normalized.id || null;
|
|
660
795
|
const existingById = explicitId
|
|
661
796
|
? db.prepare('SELECT * FROM artifacts WHERE id = ?').get(explicitId)
|
|
662
797
|
: null;
|
|
@@ -710,31 +845,25 @@ function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
710
845
|
});
|
|
711
846
|
}
|
|
712
847
|
const beforeDigest = existing?.digest || existing?.content_hash || null;
|
|
713
|
-
if (
|
|
714
|
-
&&
|
|
848
|
+
if (normalized.expectedBeforeDigest !== undefined
|
|
849
|
+
&& normalized.expectedBeforeDigest !== beforeDigest) {
|
|
715
850
|
throw new ArtifactRegistryError(
|
|
716
851
|
'ARTIFACT_DIGEST_CONFLICT',
|
|
717
852
|
`artifact ${artifactId} changed since it was read`,
|
|
718
|
-
{ expected:
|
|
853
|
+
{ expected: normalized.expectedBeforeDigest, actual: beforeDigest },
|
|
719
854
|
);
|
|
720
855
|
}
|
|
721
856
|
const afterDigest = stableRead.digest;
|
|
722
|
-
if (
|
|
857
|
+
if (normalized.contentDigest !== undefined && normalized.contentDigest !== afterDigest) {
|
|
723
858
|
throw new ArtifactRegistryError(
|
|
724
859
|
'ARTIFACT_DIGEST_CONFLICT',
|
|
725
860
|
`artifact bytes do not match the supplied digest: ${resolved.relative}`,
|
|
726
|
-
{ expected:
|
|
861
|
+
{ expected: normalized.contentDigest, actual: afterDigest },
|
|
727
862
|
);
|
|
728
863
|
}
|
|
729
864
|
const changed = beforeDigest !== afterDigest;
|
|
730
865
|
const priorConsumers = existing ? edgesForArtifact(db, artifactId).consumers : [];
|
|
731
|
-
const ownerChangeId =
|
|
732
|
-
? ownerId
|
|
733
|
-
: ownerType === 'task'
|
|
734
|
-
? db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(ownerId)?.change_id || null
|
|
735
|
-
: ownerType === 'workflow'
|
|
736
|
-
? db.prepare('SELECT change_id FROM workflow_runs WHERE id = ?').get(ownerId)?.change_id || null
|
|
737
|
-
: null;
|
|
866
|
+
const ownerChangeId = ownerRefs.changeId;
|
|
738
867
|
const now = new Date().toISOString();
|
|
739
868
|
let invalidated = [];
|
|
740
869
|
db.prepare(
|
|
@@ -754,7 +883,7 @@ function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
|
|
|
754
883
|
).run(
|
|
755
884
|
artifactId, ownerType, ownerId,
|
|
756
885
|
ownerChangeId,
|
|
757
|
-
|
|
886
|
+
ownerRefs.taskId,
|
|
758
887
|
kind, resolved.relative, afterDigest, afterDigest, beforeDigest, afterDigest,
|
|
759
888
|
JSON.stringify(provenance), JSON.stringify(metadata), status,
|
|
760
889
|
existing?.created_at || now, now,
|
|
@@ -1085,6 +1214,7 @@ module.exports = {
|
|
|
1085
1214
|
ENDPOINT_TYPES,
|
|
1086
1215
|
OWNER_TYPES,
|
|
1087
1216
|
STATUSES,
|
|
1217
|
+
assertArtifactOwner,
|
|
1088
1218
|
digestFile,
|
|
1089
1219
|
getArtifact,
|
|
1090
1220
|
inspectArtifactHealth,
|