@revoengine/cli 1.0.10 → 1.0.11
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/README.md +312 -33
- package/dist/src/cli.js +58 -7
- package/dist/src/client.d.ts +356 -5
- package/dist/src/client.js +803 -19
- package/dist/src/commands/auth.js +4 -2
- package/dist/src/commands/component.js +260 -141
- package/dist/src/commands/database-schemas.d.ts +2 -0
- package/dist/src/commands/database-schemas.js +188 -0
- package/dist/src/commands/database-views.d.ts +2 -0
- package/dist/src/commands/database-views.js +123 -0
- package/dist/src/commands/endpoints.js +114 -0
- package/dist/src/commands/env.js +44 -24
- package/dist/src/commands/events.d.ts +2 -0
- package/dist/src/commands/events.js +146 -0
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +169 -0
- package/dist/src/commands/index.d.ts +8 -0
- package/dist/src/commands/index.js +8 -0
- package/dist/src/commands/job-templates.d.ts +2 -0
- package/dist/src/commands/job-templates.js +101 -0
- package/dist/src/commands/metadata.js +29 -7
- package/dist/src/commands/project.js +10 -3
- package/dist/src/commands/role-groups.d.ts +2 -0
- package/dist/src/commands/role-groups.js +152 -0
- package/dist/src/commands/schedules.d.ts +2 -0
- package/dist/src/commands/schedules.js +141 -0
- package/dist/src/commands/terminal-service.d.ts +38 -0
- package/dist/src/commands/terminal-service.js +210 -0
- package/dist/src/commands/terminal.d.ts +22 -0
- package/dist/src/commands/terminal.js +511 -0
- package/dist/src/component-lock.d.ts +100 -2
- package/dist/src/component-lock.js +304 -15
- package/dist/src/config.d.ts +10 -2
- package/dist/src/config.js +49 -14
- package/dist/src/database-schema-artifacts.d.ts +7 -0
- package/dist/src/database-schema-artifacts.js +8 -0
- package/dist/src/env-sync.d.ts +0 -3
- package/dist/src/env-sync.js +3 -19
- package/dist/src/metadata-backfill.d.ts +16 -6
- package/dist/src/metadata-backfill.js +1069 -18
- package/dist/src/project.d.ts +2 -0
- package/dist/src/project.js +4 -17
- package/dist/src/prompt.js +10 -18
- package/dist/src/resource-metadata.d.ts +1 -0
- package/dist/src/resource-metadata.js +3 -0
- package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
- package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
- package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
- package/dist/src/resource-syncs/database-view-sync.js +1317 -0
- package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
- package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
- package/dist/src/resource-syncs/event-sync.d.ts +99 -0
- package/dist/src/resource-syncs/event-sync.js +949 -0
- package/dist/src/resource-syncs/group-sync.d.ts +86 -0
- package/dist/src/resource-syncs/group-sync.js +882 -0
- package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
- package/dist/src/resource-syncs/job-template-sync.js +782 -0
- package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
- package/dist/src/resource-syncs/role-group-sync.js +597 -0
- package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
- package/dist/src/resource-syncs/schedule-sync.js +1302 -0
- package/dist/src/resource-syncs/util.d.ts +19 -0
- package/dist/src/resource-syncs/util.js +116 -0
- package/dist/src/runtime-view.d.ts +1 -0
- package/dist/src/runtime-view.js +6 -1
- package/dist/src/sync-output.d.ts +38 -0
- package/dist/src/sync-output.js +131 -0
- package/dist/src/tracked-resources.d.ts +1 -1
- package/dist/src/tracked-resources.js +26 -2
- package/dist/src/types.d.ts +224 -0
- package/dist/src/ui.d.ts +3 -0
- package/dist/src/ui.js +67 -18
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +64 -0
- package/dist/src/workspace-component.d.ts +2 -0
- package/dist/src/workspace-component.js +34 -0
- package/dist/src/workspace-resource.d.ts +2 -0
- package/dist/src/workspace-resource.js +52 -0
- package/package.json +8 -3
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hashStable, readComponentLock, writeComponentLock } from "../component-lock.js";
|
|
3
|
+
import { normalizeStableKeyValue, readConfiguredResourceMetadataStableKey, sanitizeUserMetadata } from "../resource-metadata.js";
|
|
4
|
+
import { readJsonFile, sanitizeSegment } from "../utils.js";
|
|
5
|
+
import { findManifestFiles, isRecord, localTargetPath, requireProjectLayout, stableKeySuffix, toPortableRelativePath, unwrapData, writePulledManifest, } from "./util.js";
|
|
6
|
+
export const JOB_TEMPLATES_DIRECTORY = 'JobTemplates';
|
|
7
|
+
export const JOB_TEMPLATE_MANIFEST_FILE = 'job-template.json';
|
|
8
|
+
const DEFAULT_JOB_TEMPLATE_MEMORY_MB = 128;
|
|
9
|
+
function canonicalValue(value) {
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return value.map((item) => canonicalValue(item));
|
|
12
|
+
if (!isRecord(value))
|
|
13
|
+
return value;
|
|
14
|
+
return Object.fromEntries(Object.keys(value).sort((left, right) => left.localeCompare(right))
|
|
15
|
+
.map((key) => [key, canonicalValue(value[key])]));
|
|
16
|
+
}
|
|
17
|
+
function canonicalJobTemplateInputs(value) {
|
|
18
|
+
// The Automation API materializes a nullable inputs field as an empty object
|
|
19
|
+
// on readback. Treat both representations as the same portable state so a
|
|
20
|
+
// successful write is not reported as unverified solely for that conversion.
|
|
21
|
+
if (value === null)
|
|
22
|
+
return {};
|
|
23
|
+
return canonicalValue(value);
|
|
24
|
+
}
|
|
25
|
+
function canonicalJobTemplateOptions(value, inputs) {
|
|
26
|
+
const canonical = canonicalValue(value);
|
|
27
|
+
if (!isRecord(canonical))
|
|
28
|
+
return canonical;
|
|
29
|
+
const normalized = { ...canonical };
|
|
30
|
+
// The Automation API materializes these defaults after create even when the
|
|
31
|
+
// source omitted them. They are semantically equivalent in portable state.
|
|
32
|
+
if (normalized.memory === DEFAULT_JOB_TEMPLATE_MEMORY_MB)
|
|
33
|
+
delete normalized.memory;
|
|
34
|
+
const canonicalInputs = canonicalJobTemplateInputs(inputs);
|
|
35
|
+
const inputIsEmpty = isRecord(normalized.input) && Object.keys(normalized.input).length === 0;
|
|
36
|
+
const inputDuplicatesLegacyInputs = inputs !== undefined
|
|
37
|
+
&& JSON.stringify(normalized.input) === JSON.stringify(canonicalInputs);
|
|
38
|
+
if (inputIsEmpty || inputDuplicatesLegacyInputs)
|
|
39
|
+
delete normalized.input;
|
|
40
|
+
return Object.keys(normalized).length === 0 ? undefined : normalized;
|
|
41
|
+
}
|
|
42
|
+
function canonicalMetadata(record, stableKeyName, stableKey) {
|
|
43
|
+
const source = [record.metadata, record.metaData, record.resourceMetadata].find(isRecord);
|
|
44
|
+
return canonicalValue({
|
|
45
|
+
...sanitizeUserMetadata(source),
|
|
46
|
+
[stableKeyName]: stableKey,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function jobTemplateIdOf(record) {
|
|
50
|
+
return String(record.jobTemplateId || record.id || '');
|
|
51
|
+
}
|
|
52
|
+
function componentIdOf(record) {
|
|
53
|
+
return String(record.componentId || record.id || '');
|
|
54
|
+
}
|
|
55
|
+
export function jobTemplateManifestPath(workspaceRoot, manifest) {
|
|
56
|
+
const category = typeof manifest.category === 'string' && manifest.category.trim()
|
|
57
|
+
? sanitizeSegment(manifest.category)
|
|
58
|
+
: 'Uncategorized';
|
|
59
|
+
return path.join(workspaceRoot, JOB_TEMPLATES_DIRECTORY, category, `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, JOB_TEMPLATE_MANIFEST_FILE);
|
|
60
|
+
}
|
|
61
|
+
function portablePathIdentity(filePath) {
|
|
62
|
+
return path.resolve(filePath)
|
|
63
|
+
.normalize('NFC')
|
|
64
|
+
.split(path.sep)
|
|
65
|
+
.map((segment) => segment.replace(/[. ]+$/g, '').toLowerCase())
|
|
66
|
+
.join(path.sep);
|
|
67
|
+
}
|
|
68
|
+
function disambiguateManifestPath(filePath, stableKey) {
|
|
69
|
+
const directory = path.dirname(filePath);
|
|
70
|
+
const disambiguatedDirectory = `${path.basename(directory)}-${hashStable(stableKey).slice(0, 8)}`;
|
|
71
|
+
return path.join(path.dirname(directory), disambiguatedDirectory, path.basename(filePath));
|
|
72
|
+
}
|
|
73
|
+
function readWorkspaceJobTemplates(cwd) {
|
|
74
|
+
const { workspaceRoot } = requireProjectLayout(cwd, 'Job-template');
|
|
75
|
+
return findManifestFiles(path.join(workspaceRoot, JOB_TEMPLATES_DIRECTORY), JOB_TEMPLATE_MANIFEST_FILE).map((manifestPath) => ({
|
|
76
|
+
manifestPath,
|
|
77
|
+
manifest: readJsonFile(manifestPath),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
async function loadRemoteJobTemplates(client) {
|
|
81
|
+
const { jobTemplates } = await client.listAllJobTemplates();
|
|
82
|
+
const records = [];
|
|
83
|
+
for (const summary of jobTemplates) {
|
|
84
|
+
const id = jobTemplateIdOf(summary);
|
|
85
|
+
records.push(id ? unwrapData(await client.getJobTemplate(id)) : summary);
|
|
86
|
+
}
|
|
87
|
+
return records;
|
|
88
|
+
}
|
|
89
|
+
async function loadRemoteComponents(client) {
|
|
90
|
+
const { components } = await client.listAllComponents();
|
|
91
|
+
const records = [];
|
|
92
|
+
for (const summary of components) {
|
|
93
|
+
const id = componentIdOf(summary);
|
|
94
|
+
records.push(id ? unwrapData(await client.getComponent(id)) : summary);
|
|
95
|
+
}
|
|
96
|
+
return records;
|
|
97
|
+
}
|
|
98
|
+
const JOB_TEMPLATE_MANIFEST_FIELDS = new Set([
|
|
99
|
+
'kind', 'stableKey', 'name', 'category', 'description', 'metadata', 'component',
|
|
100
|
+
'timeout', 'options', 'inputs', 'migration',
|
|
101
|
+
]);
|
|
102
|
+
const JOB_TEMPLATE_MIGRATION_FIELDS = new Set(['componentBinding', 'executionPrincipal']);
|
|
103
|
+
function hasOnlyKeys(value, allowed) {
|
|
104
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
105
|
+
}
|
|
106
|
+
function isNonEmptyString(value) {
|
|
107
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
108
|
+
}
|
|
109
|
+
function isOptionalString(value, { nullable = false } = {}) {
|
|
110
|
+
return value === undefined || (nullable && value === null) || typeof value === 'string';
|
|
111
|
+
}
|
|
112
|
+
function isOptionalRecord(value, { nullable = false } = {}) {
|
|
113
|
+
return value === undefined || (nullable && value === null) || isRecord(value);
|
|
114
|
+
}
|
|
115
|
+
function isOptionalFiniteNumber(value) {
|
|
116
|
+
return value === undefined || (typeof value === 'number' && Number.isFinite(value));
|
|
117
|
+
}
|
|
118
|
+
function hasValidJobTemplateComponent(value) {
|
|
119
|
+
return isRecord(value)
|
|
120
|
+
&& hasOnlyKeys(value, new Set(['stableKey']))
|
|
121
|
+
&& isNonEmptyString(value.stableKey);
|
|
122
|
+
}
|
|
123
|
+
function hasValidJobTemplateMigration(value) {
|
|
124
|
+
return value === undefined || (isRecord(value)
|
|
125
|
+
&& hasOnlyKeys(value, JOB_TEMPLATE_MIGRATION_FIELDS)
|
|
126
|
+
&& (value.componentBinding === undefined || value.componentBinding === 'pinned-unsupported')
|
|
127
|
+
&& (value.executionPrincipal === undefined || value.executionPrincipal === 'excluded'));
|
|
128
|
+
}
|
|
129
|
+
function validateManifest(manifest, stableKeyName) {
|
|
130
|
+
if (!isRecord(manifest) || !hasOnlyKeys(manifest, JOB_TEMPLATE_MANIFEST_FIELDS)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if (manifest.kind !== 'job-template') {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if (!isNonEmptyString(manifest.stableKey) || !isNonEmptyString(manifest.name)) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (!isRecord(manifest.metadata) || !hasValidJobTemplateComponent(manifest.component)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (!isOptionalString(manifest.category, { nullable: true })) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (!isOptionalString(manifest.description)) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (!isOptionalFiniteNumber(manifest.timeout)) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (!isOptionalRecord(manifest.options, { nullable: true })) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (!isOptionalRecord(manifest.inputs, { nullable: true })) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (!hasValidJobTemplateMigration(manifest.migration)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return readConfiguredResourceMetadataStableKey(manifest, stableKeyName) === manifest.stableKey;
|
|
161
|
+
}
|
|
162
|
+
async function componentStableKey(input) {
|
|
163
|
+
const embedded = isRecord(input.record.component) ? input.record.component : null;
|
|
164
|
+
let component = embedded;
|
|
165
|
+
if (!readConfiguredResourceMetadataStableKey(component || {}, input.stableKeyName)) {
|
|
166
|
+
const componentId = String(input.record.componentId || embedded?.componentId || embedded?.id || '');
|
|
167
|
+
if (componentId)
|
|
168
|
+
component = unwrapData(await input.client.getComponent(componentId));
|
|
169
|
+
}
|
|
170
|
+
return readConfiguredResourceMetadataStableKey(component || {}, input.stableKeyName);
|
|
171
|
+
}
|
|
172
|
+
function manifestFromRecord(record, stableKey, componentKey, stableKeyName) {
|
|
173
|
+
return {
|
|
174
|
+
kind: 'job-template',
|
|
175
|
+
stableKey,
|
|
176
|
+
name: String(record.name || ''),
|
|
177
|
+
...(record.category === undefined ? {} : { category: record.category }),
|
|
178
|
+
...(typeof record.desc === 'string' ? { description: record.desc } : typeof record.description === 'string' ? { description: record.description } : {}),
|
|
179
|
+
metadata: canonicalMetadata(record, stableKeyName, stableKey),
|
|
180
|
+
component: { stableKey: componentKey },
|
|
181
|
+
...(typeof record.timeout === 'number' ? { timeout: record.timeout } : {}),
|
|
182
|
+
...(record.options === undefined ? {} : { options: canonicalValue(record.options) }),
|
|
183
|
+
...(record.inputs === undefined ? {} : { inputs: canonicalValue(record.inputs) }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function buildCreatePayload(manifest, componentId) {
|
|
187
|
+
return {
|
|
188
|
+
name: manifest.name,
|
|
189
|
+
...(manifest.category === undefined ? {} : { category: manifest.category }),
|
|
190
|
+
...(manifest.description === undefined ? {} : { desc: manifest.description }),
|
|
191
|
+
metadata: canonicalValue(manifest.metadata),
|
|
192
|
+
componentId,
|
|
193
|
+
...(manifest.timeout === undefined ? {} : { timeout: manifest.timeout }),
|
|
194
|
+
...(manifest.options === undefined ? {} : { options: canonicalValue(manifest.options) }),
|
|
195
|
+
...(manifest.inputs === undefined ? {} : { inputs: canonicalValue(manifest.inputs) }),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function buildUpdatePayload(manifest, componentId, version) {
|
|
199
|
+
return {
|
|
200
|
+
name: manifest.name,
|
|
201
|
+
category: manifest.category ?? null,
|
|
202
|
+
desc: manifest.description ?? '',
|
|
203
|
+
metadata: canonicalValue(manifest.metadata),
|
|
204
|
+
componentId,
|
|
205
|
+
...(manifest.timeout === undefined ? {} : { timeout: manifest.timeout }),
|
|
206
|
+
...(manifest.options === undefined ? {} : { options: canonicalValue(manifest.options) }),
|
|
207
|
+
...(manifest.inputs === undefined ? {} : { inputs: canonicalValue(manifest.inputs) }),
|
|
208
|
+
version,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export function hashPortableJobTemplate(manifest) {
|
|
212
|
+
const options = canonicalJobTemplateOptions(manifest.options, manifest.inputs);
|
|
213
|
+
return hashStable({
|
|
214
|
+
kind: 'job-template',
|
|
215
|
+
stableKey: manifest.stableKey,
|
|
216
|
+
name: manifest.name,
|
|
217
|
+
category: manifest.category ?? null,
|
|
218
|
+
description: manifest.description ?? '',
|
|
219
|
+
metadata: canonicalValue(manifest.metadata),
|
|
220
|
+
component: { stableKey: manifest.component.stableKey },
|
|
221
|
+
...(manifest.timeout === undefined ? {} : { timeout: manifest.timeout }),
|
|
222
|
+
...(options === undefined ? {} : { options }),
|
|
223
|
+
...(manifest.inputs === undefined ? {} : { inputs: canonicalJobTemplateInputs(manifest.inputs) }),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function diffJobTemplates(desired, target) {
|
|
227
|
+
const fields = [
|
|
228
|
+
['name', desired.name, target.name],
|
|
229
|
+
['category', desired.category ?? null, target.category ?? null],
|
|
230
|
+
['description', desired.description ?? '', target.description ?? ''],
|
|
231
|
+
['metadata', canonicalValue(desired.metadata), canonicalValue(target.metadata)],
|
|
232
|
+
['component.stableKey', desired.component.stableKey, target.component.stableKey],
|
|
233
|
+
['timeout', desired.timeout, target.timeout],
|
|
234
|
+
[
|
|
235
|
+
'options',
|
|
236
|
+
canonicalJobTemplateOptions(desired.options, desired.inputs),
|
|
237
|
+
canonicalJobTemplateOptions(target.options, target.inputs),
|
|
238
|
+
],
|
|
239
|
+
['inputs', canonicalJobTemplateInputs(desired.inputs), canonicalJobTemplateInputs(target.inputs)],
|
|
240
|
+
];
|
|
241
|
+
return fields
|
|
242
|
+
.filter(([, desiredValue, targetValue]) => (hashStable({ value: desiredValue }) !== hashStable({ value: targetValue })))
|
|
243
|
+
.map(([field]) => field)
|
|
244
|
+
.sort((left, right) => left.localeCompare(right));
|
|
245
|
+
}
|
|
246
|
+
function emptyPlanCounts() {
|
|
247
|
+
return {
|
|
248
|
+
clean: 0,
|
|
249
|
+
create: 0,
|
|
250
|
+
'safe-update': 0,
|
|
251
|
+
'remote-changed': 0,
|
|
252
|
+
conflict: 0,
|
|
253
|
+
'missing-lock': 0,
|
|
254
|
+
orphan: 0,
|
|
255
|
+
collision: 0,
|
|
256
|
+
skipped: 0,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
export async function buildJobTemplatesPlan(input) {
|
|
260
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Job-template');
|
|
261
|
+
const lock = readComponentLock(projectRoot);
|
|
262
|
+
const localEntries = readWorkspaceJobTemplates(input.cwd);
|
|
263
|
+
const targetRecords = await loadRemoteJobTemplates(input.client);
|
|
264
|
+
const targetComponents = await loadRemoteComponents(input.client);
|
|
265
|
+
const blockers = [];
|
|
266
|
+
const warnings = [];
|
|
267
|
+
const items = [];
|
|
268
|
+
const lockEntriesByKey = new Map(Object.values(lock.jobTemplates).map((entry) => [entry.stableKey, entry]));
|
|
269
|
+
const targetComponentKeysById = new Map();
|
|
270
|
+
const activeTargetComponentIds = new Set();
|
|
271
|
+
const targetComponentsByKey = new Map();
|
|
272
|
+
for (const component of targetComponents) {
|
|
273
|
+
const stableKey = readConfiguredResourceMetadataStableKey(component, input.stableKeyName);
|
|
274
|
+
const id = componentIdOf(component);
|
|
275
|
+
if (id)
|
|
276
|
+
activeTargetComponentIds.add(id);
|
|
277
|
+
if (!stableKey)
|
|
278
|
+
continue;
|
|
279
|
+
targetComponentsByKey.set(stableKey, [...(targetComponentsByKey.get(stableKey) || []), component]);
|
|
280
|
+
if (id)
|
|
281
|
+
targetComponentKeysById.set(id, stableKey);
|
|
282
|
+
}
|
|
283
|
+
const localByKey = new Map();
|
|
284
|
+
for (const entry of localEntries) {
|
|
285
|
+
if (!validateManifest(entry.manifest, input.stableKeyName)) {
|
|
286
|
+
blockers.push(`Invalid job-template manifest ${path.relative(projectRoot, entry.manifestPath)}.`);
|
|
287
|
+
items.push({ status: 'conflict', name: path.basename(path.dirname(entry.manifestPath)), reason: 'invalid job-template manifest' });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
localByKey.set(entry.manifest.stableKey, [...(localByKey.get(entry.manifest.stableKey) || []), entry]);
|
|
291
|
+
}
|
|
292
|
+
const targetRecordsByKey = new Map();
|
|
293
|
+
for (const record of targetRecords) {
|
|
294
|
+
const stableKey = readConfiguredResourceMetadataStableKey(record, input.stableKeyName);
|
|
295
|
+
if (!stableKey) {
|
|
296
|
+
const name = String(record.name || jobTemplateIdOf(record) || 'unknown');
|
|
297
|
+
items.push({ status: 'skipped', name, reason: `missing ${input.stableKeyName}; unmanaged target resource` });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
targetRecordsByKey.set(stableKey, [...(targetRecordsByKey.get(stableKey) || []), { record, stableKey }]);
|
|
301
|
+
if (typeof record.componentVersion === 'number') {
|
|
302
|
+
blockers.push(`Target job template "${stableKey}" uses a pinned component version, which is unsupported.`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const duplicateTargetKeys = new Set();
|
|
306
|
+
for (const [stableKey, candidates] of targetRecordsByKey) {
|
|
307
|
+
if (candidates.length < 2) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
duplicateTargetKeys.add(stableKey);
|
|
311
|
+
const selected = [...candidates].sort((left, right) => left.stableKey.localeCompare(right.stableKey)
|
|
312
|
+
|| String(left.record.name || '').localeCompare(String(right.record.name || '')))[0];
|
|
313
|
+
blockers.push(`Duplicate target job-template stableKey "${selected.stableKey}".`);
|
|
314
|
+
items.push({ status: 'collision', stableKey: selected.stableKey, name: String(selected.record.name || ''), reason: 'duplicate target stableKey' });
|
|
315
|
+
}
|
|
316
|
+
const invalidTargetKeys = new Set();
|
|
317
|
+
const targetByKey = new Map();
|
|
318
|
+
for (const [stableKeyIdentity, candidates] of targetRecordsByKey) {
|
|
319
|
+
if (duplicateTargetKeys.has(stableKeyIdentity))
|
|
320
|
+
continue;
|
|
321
|
+
const { record, stableKey } = candidates[0];
|
|
322
|
+
const embedded = isRecord(record.component) ? record.component : null;
|
|
323
|
+
const referencedComponentId = String(record.componentId || componentIdOf(embedded || {}) || '');
|
|
324
|
+
const componentKey = referencedComponentId ? targetComponentKeysById.get(referencedComponentId) : undefined;
|
|
325
|
+
if (!componentKey) {
|
|
326
|
+
invalidTargetKeys.add(stableKeyIdentity);
|
|
327
|
+
const missingKey = referencedComponentId && activeTargetComponentIds.has(referencedComponentId);
|
|
328
|
+
const reason = missingKey
|
|
329
|
+
? `references a component without ${input.stableKeyName}`
|
|
330
|
+
: 'does not resolve to an active target component';
|
|
331
|
+
blockers.push(`Target job template "${stableKey}" ${reason}.`);
|
|
332
|
+
items.push({ status: 'conflict', stableKey, name: String(record.name || ''), reason });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const manifest = manifestFromRecord(record, stableKey, componentKey, input.stableKeyName);
|
|
336
|
+
targetByKey.set(stableKeyIdentity, [{
|
|
337
|
+
record,
|
|
338
|
+
manifest,
|
|
339
|
+
hash: hashPortableJobTemplate(manifest),
|
|
340
|
+
pinned: typeof record.componentVersion === 'number',
|
|
341
|
+
}]);
|
|
342
|
+
}
|
|
343
|
+
const consumedTargetKeys = new Set();
|
|
344
|
+
for (const [stableKeyIdentity, entries] of localByKey) {
|
|
345
|
+
if (entries.length > 1) {
|
|
346
|
+
const desired = entries[0].manifest;
|
|
347
|
+
blockers.push(`Duplicate desired job-template stableKey "${desired.stableKey}".`);
|
|
348
|
+
items.push({ status: 'collision', stableKey: desired.stableKey, name: desired.name, reason: 'duplicate desired stableKey' });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const desired = entries[0].manifest;
|
|
352
|
+
const lockEntry = lockEntriesByKey.get(stableKeyIdentity);
|
|
353
|
+
if (desired.migration?.executionPrincipal === 'excluded' || lockEntry?.migration?.executionPrincipal === 'excluded') {
|
|
354
|
+
warnings.push(`Job template "${desired.stableKey}": source execution principal is excluded from migration.`);
|
|
355
|
+
}
|
|
356
|
+
if (desired.migration?.componentBinding === 'pinned-unsupported' || lockEntry?.migration?.componentBinding === 'pinned-unsupported') {
|
|
357
|
+
blockers.push(`Job template "${desired.stableKey}" uses a pinned component version, which is unsupported.`);
|
|
358
|
+
items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, reason: 'pinned component version is unsupported', componentStableKey: desired.component.stableKey });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (duplicateTargetKeys.has(stableKeyIdentity) || invalidTargetKeys.has(stableKeyIdentity)) {
|
|
362
|
+
consumedTargetKeys.add(stableKeyIdentity);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const dependencyMatches = targetComponentsByKey.get(desired.component.stableKey) || [];
|
|
366
|
+
if (dependencyMatches.length !== 1) {
|
|
367
|
+
const reason = dependencyMatches.length === 0 ? 'target component is missing' : 'target component stableKey is ambiguous';
|
|
368
|
+
blockers.push(`Job template "${desired.stableKey}" dependency "${desired.component.stableKey}": ${reason}.`);
|
|
369
|
+
items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, reason, componentStableKey: desired.component.stableKey });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const desiredHash = hashPortableJobTemplate(desired);
|
|
373
|
+
const matches = targetByKey.get(stableKeyIdentity) || [];
|
|
374
|
+
if (matches.length === 0) {
|
|
375
|
+
items.push({ status: 'create', stableKey: desired.stableKey, name: desired.name, desiredHash, componentStableKey: desired.component.stableKey });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
consumedTargetKeys.add(stableKeyIdentity);
|
|
379
|
+
const target = matches[0];
|
|
380
|
+
if (target.pinned) {
|
|
381
|
+
blockers.push(`Target job template "${desired.stableKey}" uses a pinned component version, which is unsupported.`);
|
|
382
|
+
items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, reason: 'target pinned component version is unsupported', componentStableKey: desired.component.stableKey });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const changedFields = diffJobTemplates(desired, target.manifest);
|
|
386
|
+
const changeDetails = changedFields.length > 0 ? { changedFields } : {};
|
|
387
|
+
const baseline = lockEntry?.environments[input.envName]?.remoteHash;
|
|
388
|
+
if (!baseline) {
|
|
389
|
+
items.push({ status: 'missing-lock', stableKey: desired.stableKey, name: desired.name, desiredHash, currentHash: target.hash, reason: `missing ${input.envName} lock baseline`, componentStableKey: desired.component.stableKey, ...changeDetails });
|
|
390
|
+
}
|
|
391
|
+
else if (desiredHash === target.hash) {
|
|
392
|
+
items.push({ status: 'clean', stableKey: desired.stableKey, name: desired.name, desiredHash, currentHash: target.hash, componentStableKey: desired.component.stableKey });
|
|
393
|
+
}
|
|
394
|
+
else if (target.hash === baseline) {
|
|
395
|
+
items.push({ status: 'safe-update', stableKey: desired.stableKey, name: desired.name, desiredHash, currentHash: target.hash, baselineHash: baseline, reason: 'local manifest changed; target matches lock baseline', componentStableKey: desired.component.stableKey, ...changeDetails });
|
|
396
|
+
}
|
|
397
|
+
else if (desiredHash === baseline) {
|
|
398
|
+
items.push({ status: 'remote-changed', stableKey: desired.stableKey, name: desired.name, desiredHash, currentHash: target.hash, baselineHash: baseline, reason: 'target changed; local manifest matches lock baseline', componentStableKey: desired.component.stableKey, ...changeDetails });
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
items.push({ status: 'conflict', stableKey: desired.stableKey, name: desired.name, desiredHash, currentHash: target.hash, baselineHash: baseline, reason: 'local manifest and target both changed since lock baseline', componentStableKey: desired.component.stableKey, ...changeDetails });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const [stableKeyIdentity, matches] of targetByKey) {
|
|
405
|
+
if (consumedTargetKeys.has(stableKeyIdentity) || localByKey.has(stableKeyIdentity))
|
|
406
|
+
continue;
|
|
407
|
+
const target = matches[0];
|
|
408
|
+
items.push({ status: 'orphan', stableKey: target.manifest.stableKey, name: target.manifest.name, currentHash: target.hash, reason: 'target-only job-template' });
|
|
409
|
+
}
|
|
410
|
+
const rank = {
|
|
411
|
+
create: 0,
|
|
412
|
+
'safe-update': 1,
|
|
413
|
+
'remote-changed': 2,
|
|
414
|
+
conflict: 3,
|
|
415
|
+
'missing-lock': 4,
|
|
416
|
+
clean: 5,
|
|
417
|
+
orphan: 6,
|
|
418
|
+
collision: 7,
|
|
419
|
+
skipped: 8,
|
|
420
|
+
};
|
|
421
|
+
items.sort((left, right) => {
|
|
422
|
+
const leftKey = left.stableKey || left.name;
|
|
423
|
+
const rightKey = right.stableKey || right.name;
|
|
424
|
+
return rank[left.status] - rank[right.status]
|
|
425
|
+
|| normalizeStableKeyValue(leftKey).localeCompare(normalizeStableKeyValue(rightKey))
|
|
426
|
+
|| leftKey.localeCompare(rightKey);
|
|
427
|
+
});
|
|
428
|
+
const counts = emptyPlanCounts();
|
|
429
|
+
for (const item of items)
|
|
430
|
+
counts[item.status] += 1;
|
|
431
|
+
return {
|
|
432
|
+
resourceType: 'job-template',
|
|
433
|
+
env: input.envName,
|
|
434
|
+
counts,
|
|
435
|
+
blockers: [...new Set(blockers)].sort((left, right) => left.localeCompare(right)),
|
|
436
|
+
warnings: [...new Set(warnings)].sort((left, right) => left.localeCompare(right)),
|
|
437
|
+
items,
|
|
438
|
+
exclusions: ['UUID, version, componentVersion and triggerUser are never persisted', 'target-only orphans are informational and never deleted'],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
export async function pullJobTemplatesToWorkspace(input) {
|
|
442
|
+
const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Job-template');
|
|
443
|
+
const { jobTemplates } = await input.client.listAllJobTemplates();
|
|
444
|
+
const manifests = [];
|
|
445
|
+
const warnings = [];
|
|
446
|
+
const missing = [];
|
|
447
|
+
const stableKeys = new Set();
|
|
448
|
+
for (const summary of jobTemplates) {
|
|
449
|
+
const id = jobTemplateIdOf(summary);
|
|
450
|
+
const record = id ? unwrapData(await input.client.getJobTemplate(id)) : summary;
|
|
451
|
+
const stableKey = readConfiguredResourceMetadataStableKey(record, input.stableKeyName);
|
|
452
|
+
if (!stableKey) {
|
|
453
|
+
missing.push(String(record.name || id || 'unknown'));
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (stableKeys.has(stableKey))
|
|
457
|
+
throw new Error(`Duplicate job-template stableKey "${stableKey}" on ${input.envName}; no files were changed.`);
|
|
458
|
+
stableKeys.add(stableKey);
|
|
459
|
+
const dependencyStableKey = await componentStableKey({ client: input.client, record, stableKeyName: input.stableKeyName });
|
|
460
|
+
if (!dependencyStableKey)
|
|
461
|
+
throw new Error(`Job template "${stableKey}" references a component without ${input.stableKeyName}; no files were changed.`);
|
|
462
|
+
const migration = {
|
|
463
|
+
...(typeof record.componentVersion === 'number' ? { componentBinding: 'pinned-unsupported' } : {}),
|
|
464
|
+
...(record.triggerUser ? { executionPrincipal: 'excluded' } : {}),
|
|
465
|
+
};
|
|
466
|
+
manifests.push({
|
|
467
|
+
...manifestFromRecord(record, stableKey, dependencyStableKey, input.stableKeyName),
|
|
468
|
+
...(Object.keys(migration).length > 0 ? { migration } : {}),
|
|
469
|
+
});
|
|
470
|
+
if (record.triggerUser)
|
|
471
|
+
warnings.push({ stableKey, reason: 'triggerUser execution principal skipped' });
|
|
472
|
+
}
|
|
473
|
+
if (missing.length > 0)
|
|
474
|
+
throw new Error(`Active source job template(s) missing ${input.stableKeyName}: ${missing.sort().join(', ')}; no files were changed.`);
|
|
475
|
+
manifests.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
|
|
476
|
+
warnings.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
|
|
477
|
+
const lock = readComponentLock(projectRoot);
|
|
478
|
+
const existingByKey = new Map();
|
|
479
|
+
const existingByPath = new Map();
|
|
480
|
+
for (const entry of readWorkspaceJobTemplates(input.cwd)) {
|
|
481
|
+
if (!validateManifest(entry.manifest, input.stableKeyName)) {
|
|
482
|
+
throw new Error(`Cannot pull over invalid job-template manifest ${path.relative(projectRoot, entry.manifestPath)}.`);
|
|
483
|
+
}
|
|
484
|
+
if (existingByKey.has(entry.manifest.stableKey))
|
|
485
|
+
throw new Error(`Duplicate local job-template stableKey "${entry.manifest.stableKey}"; no files were changed.`);
|
|
486
|
+
existingByKey.set(entry.manifest.stableKey, entry);
|
|
487
|
+
const pathIdentity = portablePathIdentity(entry.manifestPath);
|
|
488
|
+
existingByPath.set(pathIdentity, [...(existingByPath.get(pathIdentity) || []), entry]);
|
|
489
|
+
}
|
|
490
|
+
const writes = manifests.map((manifest) => {
|
|
491
|
+
const hash = hashPortableJobTemplate(manifest);
|
|
492
|
+
const existing = existingByKey.get(manifest.stableKey);
|
|
493
|
+
if (existing && !input.force) {
|
|
494
|
+
const localHash = hashPortableJobTemplate(existing.manifest);
|
|
495
|
+
const baseline = lock.jobTemplates[manifest.stableKey];
|
|
496
|
+
if (localHash !== hash && (!baseline || localHash !== baseline.sourceHash)) {
|
|
497
|
+
throw new Error(`Cannot pull job-template "${manifest.stableKey}": local manifest changed since the lock baseline. Re-run with --force.`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const filePath = jobTemplateManifestPath(workspaceRoot, manifest);
|
|
501
|
+
return { manifest, hash, filePath, previousPath: existing?.manifestPath };
|
|
502
|
+
});
|
|
503
|
+
const candidatePathCounts = new Map();
|
|
504
|
+
for (const write of writes) {
|
|
505
|
+
const identity = portablePathIdentity(write.filePath);
|
|
506
|
+
candidatePathCounts.set(identity, (candidatePathCounts.get(identity) || 0) + 1);
|
|
507
|
+
}
|
|
508
|
+
for (const write of writes) {
|
|
509
|
+
if ((candidatePathCounts.get(portablePathIdentity(write.filePath)) || 0) > 1) {
|
|
510
|
+
write.filePath = disambiguateManifestPath(write.filePath, write.manifest.stableKey);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const paths = new Set();
|
|
514
|
+
for (const write of writes) {
|
|
515
|
+
const pathIdentity = portablePathIdentity(write.filePath);
|
|
516
|
+
if (paths.has(pathIdentity))
|
|
517
|
+
throw new Error('Multiple job templates resolve to the same workspace path; no files were changed.');
|
|
518
|
+
paths.add(pathIdentity);
|
|
519
|
+
const pathOwner = (existingByPath.get(pathIdentity) || [])
|
|
520
|
+
.find((entry) => entry.manifest.stableKey !== write.manifest.stableKey);
|
|
521
|
+
if (pathOwner) {
|
|
522
|
+
throw new Error(`Job-template workspace path "${path.relative(projectRoot, write.filePath)}" is occupied by different stableKey "${pathOwner.manifest.stableKey}"; no files were changed.`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const root = path.join(workspaceRoot, JOB_TEMPLATES_DIRECTORY);
|
|
526
|
+
let lockChanged = false;
|
|
527
|
+
for (const write of writes) {
|
|
528
|
+
writePulledManifest({
|
|
529
|
+
filePath: write.filePath,
|
|
530
|
+
manifest: write.manifest,
|
|
531
|
+
previousPath: write.previousPath,
|
|
532
|
+
root: root,
|
|
533
|
+
force: input.force,
|
|
534
|
+
});
|
|
535
|
+
const previous = lock.jobTemplates[write.manifest.stableKey];
|
|
536
|
+
const previousBaseline = previous?.environments[input.envName];
|
|
537
|
+
const entry = {
|
|
538
|
+
stableKey: write.manifest.stableKey,
|
|
539
|
+
path: toPortableRelativePath(projectRoot, write.filePath),
|
|
540
|
+
sourceHash: write.hash,
|
|
541
|
+
...(write.manifest.migration
|
|
542
|
+
? { migration: write.manifest.migration }
|
|
543
|
+
: {}),
|
|
544
|
+
environments: {
|
|
545
|
+
...(previous?.environments || {}),
|
|
546
|
+
[input.envName]: previousBaseline?.remoteHash === write.hash
|
|
547
|
+
? previousBaseline
|
|
548
|
+
: { remoteHash: write.hash, syncedAt: new Date().toISOString() },
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
if (!previous || hashStable(previous) !== hashStable(entry))
|
|
552
|
+
lockChanged = true;
|
|
553
|
+
lock.jobTemplates[write.manifest.stableKey] = entry;
|
|
554
|
+
}
|
|
555
|
+
if (lockChanged)
|
|
556
|
+
writeComponentLock(projectRoot, lock);
|
|
557
|
+
return {
|
|
558
|
+
pulled: writes.map((write) => ({ manifest: write.manifest, targetPath: localTargetPath(input.cwd, write.filePath) })),
|
|
559
|
+
warnings,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
async function prepareJobTemplateCreates(input) {
|
|
563
|
+
const localByKey = new Map(readWorkspaceJobTemplates(input.cwd)
|
|
564
|
+
.filter((entry) => validateManifest(entry.manifest, input.stableKeyName))
|
|
565
|
+
.map((entry) => [entry.manifest.stableKey, entry]));
|
|
566
|
+
const targetTemplates = await loadRemoteJobTemplates(input.client);
|
|
567
|
+
const targetKeys = targetTemplates.map((record) => readConfiguredResourceMetadataStableKey(record, input.stableKeyName));
|
|
568
|
+
const targetComponents = await loadRemoteComponents(input.client);
|
|
569
|
+
const prepared = [];
|
|
570
|
+
for (const item of input.plan.items.filter((candidate) => candidate.status === 'create')) {
|
|
571
|
+
const stableKey = item.stableKey || '';
|
|
572
|
+
const local = localByKey.get(stableKey);
|
|
573
|
+
if (!local)
|
|
574
|
+
throw new Error(`Job-template push blocked: desired manifest "${stableKey}" disappeared after planning.`);
|
|
575
|
+
const desiredHash = hashPortableJobTemplate(local.manifest);
|
|
576
|
+
if (desiredHash !== item.desiredHash)
|
|
577
|
+
throw new Error(`Job-template push blocked: desired manifest "${stableKey}" changed after planning.`);
|
|
578
|
+
if (targetKeys.some((key) => key === stableKey)) {
|
|
579
|
+
throw new Error(`Job-template push blocked: target job template "${stableKey}" appeared after planning.`);
|
|
580
|
+
}
|
|
581
|
+
const componentMatches = targetComponents.filter((component) => (readConfiguredResourceMetadataStableKey(component, input.stableKeyName) === local.manifest.component.stableKey));
|
|
582
|
+
if (componentMatches.length !== 1) {
|
|
583
|
+
throw new Error(`Job-template push blocked: dependency "${local.manifest.component.stableKey}" expected one active target component, found ${componentMatches.length}.`);
|
|
584
|
+
}
|
|
585
|
+
const componentId = componentIdOf(componentMatches[0]);
|
|
586
|
+
if (!componentId)
|
|
587
|
+
throw new Error(`Job-template push blocked: dependency "${local.manifest.component.stableKey}" has no target component id.`);
|
|
588
|
+
prepared.push({ manifest: local.manifest, manifestPath: local.manifestPath, desiredHash, componentId });
|
|
589
|
+
}
|
|
590
|
+
return prepared.sort((left, right) => (normalizeStableKeyValue(left.manifest.stableKey).localeCompare(normalizeStableKeyValue(right.manifest.stableKey))
|
|
591
|
+
|| left.manifest.stableKey.localeCompare(right.manifest.stableKey)));
|
|
592
|
+
}
|
|
593
|
+
function updateJobTemplateEnvironmentBaseline(input) {
|
|
594
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Job-template');
|
|
595
|
+
const lock = readComponentLock(projectRoot);
|
|
596
|
+
const stableKey = input.manifest.stableKey;
|
|
597
|
+
const existingPair = Object.entries(lock.jobTemplates).find(([, entry]) => entry.stableKey === stableKey);
|
|
598
|
+
const existing = existingPair?.[1];
|
|
599
|
+
if (existingPair && existingPair[0] !== stableKey)
|
|
600
|
+
delete lock.jobTemplates[existingPair[0]];
|
|
601
|
+
const previousBaseline = existing?.environments[input.envName];
|
|
602
|
+
lock.jobTemplates[stableKey] = {
|
|
603
|
+
stableKey,
|
|
604
|
+
path: toPortableRelativePath(projectRoot, input.manifestPath),
|
|
605
|
+
sourceHash: hashPortableJobTemplate(input.manifest),
|
|
606
|
+
...(input.manifest.migration ? { migration: input.manifest.migration } : {}),
|
|
607
|
+
environments: {
|
|
608
|
+
...(existing?.environments || {}),
|
|
609
|
+
[input.envName]: previousBaseline?.remoteHash === input.remoteHash
|
|
610
|
+
? previousBaseline
|
|
611
|
+
: { remoteHash: input.remoteHash, syncedAt: new Date().toISOString() },
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
writeComponentLock(projectRoot, lock);
|
|
615
|
+
}
|
|
616
|
+
export async function pushJobTemplates(input) {
|
|
617
|
+
const plan = await buildJobTemplatesPlan(input);
|
|
618
|
+
if (plan.blockers.length > 0) {
|
|
619
|
+
throw new Error(`Job-template push blocked: ${plan.blockers.join(' ')}`);
|
|
620
|
+
}
|
|
621
|
+
const contentBlockers = plan.items.filter((item) => ['missing-lock', 'remote-changed', 'conflict'].includes(item.status));
|
|
622
|
+
if (contentBlockers.length > 0 && !input.force) {
|
|
623
|
+
const statuses = [...new Set(contentBlockers.map((item) => item.status))].sort().join(', ');
|
|
624
|
+
throw new Error(`Job-template push blocked by ${statuses}; review the plan and re-run with --force only to adopt or overwrite reviewed content drift.`);
|
|
625
|
+
}
|
|
626
|
+
const preparedCreates = await prepareJobTemplateCreates({ ...input, plan });
|
|
627
|
+
const results = [];
|
|
628
|
+
for (const prepared of preparedCreates) {
|
|
629
|
+
const stableKey = prepared.manifest.stableKey;
|
|
630
|
+
const targetPath = localTargetPath(input.cwd, prepared.manifestPath);
|
|
631
|
+
try {
|
|
632
|
+
const created = unwrapData(await input.client.createJobTemplate(buildCreatePayload(prepared.manifest, prepared.componentId)));
|
|
633
|
+
const createdId = jobTemplateIdOf(created);
|
|
634
|
+
if (!createdId)
|
|
635
|
+
throw new Error('create response did not include jobTemplateId');
|
|
636
|
+
const verified = unwrapData(await input.client.getJobTemplate(createdId));
|
|
637
|
+
const verifiedStableKey = readConfiguredResourceMetadataStableKey(verified, input.stableKeyName);
|
|
638
|
+
if (!verifiedStableKey)
|
|
639
|
+
throw new Error(`created job template is missing ${input.stableKeyName}`);
|
|
640
|
+
if (verifiedStableKey !== stableKey)
|
|
641
|
+
throw new Error(`created job template ${input.stableKeyName} does not match desired identity`);
|
|
642
|
+
const verifiedComponentKey = await componentStableKey({ client: input.client, record: verified, stableKeyName: input.stableKeyName });
|
|
643
|
+
if (!verifiedComponentKey)
|
|
644
|
+
throw new Error(`created job template references a component without ${input.stableKeyName}`);
|
|
645
|
+
const verifiedManifest = manifestFromRecord(verified, verifiedStableKey, verifiedComponentKey, input.stableKeyName);
|
|
646
|
+
if (hashPortableJobTemplate(verifiedManifest) !== prepared.desiredHash) {
|
|
647
|
+
throw new Error('created job template did not match desired portable hash');
|
|
648
|
+
}
|
|
649
|
+
updateJobTemplateEnvironmentBaseline({
|
|
650
|
+
cwd: input.cwd,
|
|
651
|
+
envName: input.envName,
|
|
652
|
+
manifest: prepared.manifest,
|
|
653
|
+
manifestPath: prepared.manifestPath,
|
|
654
|
+
remoteHash: prepared.desiredHash,
|
|
655
|
+
});
|
|
656
|
+
results.push({ stableKey, action: 'create', status: 'deployed', targetPath });
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
results.push({
|
|
660
|
+
stableKey,
|
|
661
|
+
action: 'create',
|
|
662
|
+
status: 'failed',
|
|
663
|
+
targetPath,
|
|
664
|
+
error: error instanceof Error ? error.message : String(error),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const localByKey = new Map(readWorkspaceJobTemplates(input.cwd).map((entry) => [entry.manifest.stableKey, entry]));
|
|
669
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'clean')) {
|
|
670
|
+
const local = item.stableKey ? localByKey.get(item.stableKey) : undefined;
|
|
671
|
+
if (local && item.currentHash) {
|
|
672
|
+
updateJobTemplateEnvironmentBaseline({
|
|
673
|
+
cwd: input.cwd,
|
|
674
|
+
envName: input.envName,
|
|
675
|
+
manifest: local.manifest,
|
|
676
|
+
manifestPath: local.manifestPath,
|
|
677
|
+
remoteHash: item.currentHash,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
results.push({
|
|
681
|
+
stableKey: item.stableKey || item.name,
|
|
682
|
+
action: 'noop',
|
|
683
|
+
status: 'skipped',
|
|
684
|
+
targetPath: local ? localTargetPath(input.cwd, local.manifestPath) : item.stableKey || item.name,
|
|
685
|
+
reason: 'no local changes',
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
const updateItems = plan.items.filter((candidate) => (['safe-update', 'remote-changed', 'conflict', 'missing-lock'].includes(candidate.status)));
|
|
689
|
+
const liveTargetTemplates = updateItems.length > 0 ? await loadRemoteJobTemplates(input.client) : [];
|
|
690
|
+
const liveTargetComponents = updateItems.length > 0 ? await loadRemoteComponents(input.client) : [];
|
|
691
|
+
for (const item of updateItems) {
|
|
692
|
+
const stableKey = item.stableKey || '';
|
|
693
|
+
const local = localByKey.get(stableKey);
|
|
694
|
+
const targetPath = local ? localTargetPath(input.cwd, local.manifestPath) : stableKey;
|
|
695
|
+
try {
|
|
696
|
+
if (!local)
|
|
697
|
+
throw new Error('desired manifest disappeared after planning');
|
|
698
|
+
const desiredHash = hashPortableJobTemplate(local.manifest);
|
|
699
|
+
if (desiredHash !== item.desiredHash)
|
|
700
|
+
throw new Error('desired manifest changed after planning');
|
|
701
|
+
const targetMatches = liveTargetTemplates.filter((record) => (readConfiguredResourceMetadataStableKey(record, input.stableKeyName) === stableKey));
|
|
702
|
+
if (targetMatches.length !== 1)
|
|
703
|
+
throw new Error(`expected one live target match, found ${targetMatches.length}`);
|
|
704
|
+
const targetId = jobTemplateIdOf(targetMatches[0]);
|
|
705
|
+
if (!targetId)
|
|
706
|
+
throw new Error('live target did not include jobTemplateId');
|
|
707
|
+
const target = unwrapData(await input.client.getJobTemplate(targetId));
|
|
708
|
+
const targetStableKey = readConfiguredResourceMetadataStableKey(target, input.stableKeyName);
|
|
709
|
+
if (targetStableKey !== stableKey)
|
|
710
|
+
throw new Error('target stableKey changed after planning');
|
|
711
|
+
const targetComponentKey = await componentStableKey({ client: input.client, record: target, stableKeyName: input.stableKeyName });
|
|
712
|
+
if (!targetComponentKey)
|
|
713
|
+
throw new Error(`target dependency is missing ${input.stableKeyName}`);
|
|
714
|
+
const targetManifest = manifestFromRecord(target, targetStableKey, targetComponentKey, input.stableKeyName);
|
|
715
|
+
const targetHash = hashPortableJobTemplate(targetManifest);
|
|
716
|
+
if (targetHash !== item.currentHash)
|
|
717
|
+
throw new Error('target changed after planning');
|
|
718
|
+
if (item.status === 'missing-lock' && desiredHash === targetHash) {
|
|
719
|
+
updateJobTemplateEnvironmentBaseline({
|
|
720
|
+
cwd: input.cwd,
|
|
721
|
+
envName: input.envName,
|
|
722
|
+
manifest: local.manifest,
|
|
723
|
+
manifestPath: local.manifestPath,
|
|
724
|
+
remoteHash: desiredHash,
|
|
725
|
+
});
|
|
726
|
+
results.push({
|
|
727
|
+
stableKey,
|
|
728
|
+
action: 'noop',
|
|
729
|
+
status: 'deployed',
|
|
730
|
+
targetPath,
|
|
731
|
+
reason: 'force-adopted existing target into lock baseline',
|
|
732
|
+
});
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (typeof target.version !== 'number')
|
|
736
|
+
throw new Error('live target did not include version');
|
|
737
|
+
const componentMatches = liveTargetComponents.filter((component) => (readConfiguredResourceMetadataStableKey(component, input.stableKeyName) === local.manifest.component.stableKey));
|
|
738
|
+
if (componentMatches.length !== 1) {
|
|
739
|
+
throw new Error(`dependency "${local.manifest.component.stableKey}" expected one active target component, found ${componentMatches.length}`);
|
|
740
|
+
}
|
|
741
|
+
const componentId = componentIdOf(componentMatches[0]);
|
|
742
|
+
if (!componentId)
|
|
743
|
+
throw new Error(`dependency "${local.manifest.component.stableKey}" has no target component id`);
|
|
744
|
+
await input.client.updateJobTemplate(targetId, buildUpdatePayload(local.manifest, componentId, target.version));
|
|
745
|
+
const verified = unwrapData(await input.client.getJobTemplate(targetId));
|
|
746
|
+
const verifiedStableKey = readConfiguredResourceMetadataStableKey(verified, input.stableKeyName);
|
|
747
|
+
if (verifiedStableKey !== stableKey)
|
|
748
|
+
throw new Error(`updated target is missing or changed ${input.stableKeyName}`);
|
|
749
|
+
const verifiedComponentKey = await componentStableKey({ client: input.client, record: verified, stableKeyName: input.stableKeyName });
|
|
750
|
+
if (!verifiedComponentKey)
|
|
751
|
+
throw new Error(`updated target dependency is missing ${input.stableKeyName}`);
|
|
752
|
+
const verifiedManifest = manifestFromRecord(verified, verifiedStableKey, verifiedComponentKey, input.stableKeyName);
|
|
753
|
+
if (hashPortableJobTemplate(verifiedManifest) !== desiredHash) {
|
|
754
|
+
throw new Error('updated job template did not match desired portable hash');
|
|
755
|
+
}
|
|
756
|
+
updateJobTemplateEnvironmentBaseline({
|
|
757
|
+
cwd: input.cwd,
|
|
758
|
+
envName: input.envName,
|
|
759
|
+
manifest: local.manifest,
|
|
760
|
+
manifestPath: local.manifestPath,
|
|
761
|
+
remoteHash: desiredHash,
|
|
762
|
+
});
|
|
763
|
+
results.push({ stableKey, action: 'update', status: 'deployed', targetPath });
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
results.push({
|
|
767
|
+
stableKey,
|
|
768
|
+
action: 'update',
|
|
769
|
+
status: 'failed',
|
|
770
|
+
targetPath,
|
|
771
|
+
error: error instanceof Error ? error.message : String(error),
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'orphan')) {
|
|
776
|
+
results.push({ stableKey: item.stableKey || item.name, action: 'orphan', status: 'skipped', targetPath: item.stableKey || item.name, reason: 'target-only orphan' });
|
|
777
|
+
}
|
|
778
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'skipped')) {
|
|
779
|
+
results.push({ stableKey: item.stableKey || item.name, action: 'skipped', status: 'skipped', targetPath: item.stableKey || item.name, reason: item.reason || 'unmanaged' });
|
|
780
|
+
}
|
|
781
|
+
return { plan, results };
|
|
782
|
+
}
|