Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ parameter semantics in the `docs { }` block.
deletable "graph cache" to release-track exports; drafts inherit their
predecessor's manifest and only member-changing writes seal a new one. The
`x-mitre-collection` object is a projection, not a stored object.
- A virtual track's `snapshot_schedule` is live registry configuration, not
historical snapshot state. Schedule changes must update the registry without
cloning a draft; Workbench snapshot responses project the current schedule.
- Historic full-suite flake (fixed 2026-07-10): per-spec-file mongod
restarts hit "Port already in use", failing a random file's `before` hook
(visible as `loginAnonymous` 404s). `database-in-memory.js` now reuses one
Expand Down
7 changes: 7 additions & 0 deletions app/api/definitions/components/release-tracks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ components:
one component, while conflicts include only IDs with genuinely
different revisions. Each surviving member is attributed to exactly
one component in objects_contributed.
snapshot_schedule:
readOnly: true
description: |
Current registry-backed materialization schedule for virtual tracks
in Workbench-format responses. It is not historical snapshot data.
allOf:
- $ref: '#/components/schemas/snapshot-schedule'
scheduled_materialization:
nullable: true
description: |
Expand Down
3 changes: 3 additions & 0 deletions app/api/definitions/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,9 @@ paths:
/api/release-tracks/{id}/virtual/composition:
$ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1composition'

/api/release-tracks/{id}/virtual/schedule:
$ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1schedule'

/api/release-tracks/{id}/virtual/snapshots/create:
$ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1snapshots~1create'

Expand Down
42 changes: 42 additions & 0 deletions app/api/definitions/paths/release-tracks-paths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,48 @@ paths:
'400':
description: 'Track is not virtual or composition is invalid'

/api/release-tracks/{id}/virtual/schedule:
put:
summary: 'Update a virtual track snapshot schedule'
operationId: 'release-tracks-schedule-update'
description: |
Replace the registry-backed materialization schedule for a virtual
track without creating or mutating a content snapshot. Request bodies
are strictly validated via Zod: manual accepts only mode, cron requires
one five-field UTC expression, and dates requires at least one ISO UTC
timestamp. Scheduler reconciliation observes the replacement on its
next configured pass.
tags:
- 'Release Tracks'
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: 'Snapshot schedule updated successfully'
content:
application/json:
schema:
type: object
required:
- snapshot_schedule
properties:
snapshot_schedule:
$ref: '../components/release-tracks.yml#/components/schemas/snapshot-schedule'
'400':
description: 'Track is not virtual or schedule is invalid'
'404':
description: 'Release track not found'

/api/release-tracks/{id}/virtual/snapshots/create:
post:
summary: 'Create a virtual track snapshot'
Expand Down
23 changes: 23 additions & 0 deletions app/controllers/release-tracks-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const {
updateCandidateVersionBodySchema,
updateConfigBodySchema,
updateCompositionBodySchema,
updateScheduleBodySchema,
createVirtualSnapshotBodySchema,
promoteQuarantinedObjectBodySchema,
reconstructSnapshotGraphBodySchema,
Expand Down Expand Up @@ -1014,6 +1015,28 @@ exports.updateComposition = async function updateComposition(req, res, next) {
}
};

/** PUT /api/release-tracks/:id/virtual/schedule */
exports.updateSchedule = async function updateSchedule(req, res, next) {
try {
const bodyResult = updateScheduleBodySchema.safeParse(req.body);
if (!bodyResult.success) {
return next(
new BadRequestError({
message: 'Invalid snapshot schedule update',
details: bodyResult.error.errors,
}),
);
}

const result = await releaseTracksService.updateSchedule(req.params.id, bodyResult.data);
logger.debug(`Success: Updated snapshot schedule for track ${req.params.id}`);
return res.status(200).send(result);
} catch (err) {
logger.error('Failed to update snapshot schedule: ' + err);
return next(err);
}
};

/** POST /api/release-tracks/:id/virtual/snapshots/create */
exports.createVirtualSnapshot = async function createVirtualSnapshot(req, res, next) {
try {
Expand Down
1 change: 1 addition & 0 deletions app/lib/release-tracks/release-track-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ module.exports = {
updateCandidateVersionBodySchema,
updateConfigBodySchema,
updateCompositionBodySchema,
updateScheduleBodySchema: snapshotScheduleSchema,
createVirtualSnapshotBodySchema,
promoteQuarantinedObjectBodySchema,
reconstructSnapshotGraphBodySchema,
Expand Down
3 changes: 2 additions & 1 deletion app/models/release-tracks/release-track-registry-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ const releaseTrackRegistryDefinition = {
default: undefined,
validate: {
validator: function validateRegistrySnapshotSchedule(value) {
const trackType = typeof this.getQuery === 'function' ? this.getQuery().type : this.type;
return (
value === undefined ||
(this.type === 'virtual' && validateSnapshotSchedule.validator(value))
(trackType === 'virtual' && validateSnapshotSchedule.validator(value))
);
},
message:
Expand Down
19 changes: 19 additions & 0 deletions app/repository/release-tracks/release-track-registry.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,25 @@ class ReleaseTrackRegistryRepository {
}
}

async setSnapshotSchedule(trackId, snapshotSchedule) {
try {
return await this.model
.findOneAndUpdate(
{ track_id: trackId, type: 'virtual' },
{
$set: {
snapshot_schedule: snapshotSchedule,
updated_at: new Date(),
},
},
{ new: true, runValidators: true, lean: true },
)
.exec();
} catch (err) {
throw new DatabaseError(err);
}
}

async replaceTaggedReleases(trackId, taggedReleases, latestTaggedVersion) {
try {
return await this.model
Expand Down
8 changes: 8 additions & 0 deletions app/routes/release-tracks-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,14 @@ router
releaseTracksController.updateComposition,
);

router
.route('/release-tracks/:id/virtual/schedule')
.put(
authn.authenticate,
authz.requireRole(authz.editorOrHigher),
releaseTracksController.updateSchedule,
);

// =============================================================================
// Delete release track (must be last -- :id is a catch-all param)
// =============================================================================
Expand Down
19 changes: 17 additions & 2 deletions app/services/release-tracks/release-tracks-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,12 @@ async function formatWorkbenchSnapshot(snapshot, options) {
selectedTiers.flatMap((tierName) => snapshot[tierName] || []),
);
const enriched = await addObjectInfoToSnapshot(snapshot);
// Registry-derived, read-only: lets clients build alias URLs for the track.
enriched.alias = await snapshotService.getTrackAlias(snapshot.id);
// Registry-derived, read-only metadata used alongside snapshot content.
const metadata = await snapshotService.getTrackMetadata(snapshot.id);
enriched.alias = metadata.alias;
if (snapshot.type === 'virtual') {
enriched.snapshot_schedule = metadata.snapshot_schedule || { mode: 'manual' };
}
return filterSnapshotTiers(enriched, options?.include);
}

Expand Down Expand Up @@ -539,6 +543,17 @@ exports.updateComposition = function updateComposition(trackId, composition, use
});
};

exports.updateSchedule = function updateSchedule(trackId, schedule) {
const scheduleResult = snapshotScheduleSchema.safeParse(schedule);
if (!scheduleResult.success) {
throw new BadRequestError({
message: 'Invalid snapshot schedule',
details: scheduleResult.error.errors,
});
}
return virtualTrackService.updateSchedule(trackId, scheduleResult.data);
};

exports.createVirtualSnapshot = function createVirtualSnapshot(trackId, options) {
let validatedOptions = options;
if (options?.scheduledMaterialization !== undefined) {
Expand Down
7 changes: 5 additions & 2 deletions app/services/release-tracks/snapshot-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,12 @@ exports.resolveTrackAlias = async function resolveTrackAlias(alias) {
/**
* The alias registered for a track, or null.
*/
exports.getTrackAlias = async function getTrackAlias(trackId) {
exports.getTrackMetadata = async function getTrackMetadata(trackId) {
const entry = await registryRepo.findByTrackId(trackId);
return entry?.alias ?? null;
return {
alias: entry?.alias ?? null,
snapshot_schedule: entry?.snapshot_schedule,
};
};

// =============================================================================
Expand Down
28 changes: 28 additions & 0 deletions app/services/release-tracks/virtual-track-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,34 @@ exports.updateComposition = async function updateComposition(
return snapshot;
};

/**
* Replace the persisted materialization schedule for a virtual track.
* The registry is authoritative so schedule changes do not create or mutate a
* content snapshot. The scheduler reconciliation task observes the new value.
*
* @param {string} trackId
* @param {Object} schedule
* @returns {Promise<{snapshot_schedule: Object}>}
*/
exports.updateSchedule = async function updateSchedule(trackId, schedule) {
const registry = await registryRepo.findByTrackId(trackId);
if (!registry) {
throw new TrackNotFoundError(trackId);
}
if (registry.type !== 'virtual') {
throw new BadRequestError({
message: 'This operation is only available for virtual release tracks',
details: `Track ${trackId} is a ${registry.type} track`,
});
}

const updated = await registryRepo.setSnapshotSchedule(trackId, schedule);
logger.verbose(
`VirtualTrackService: Updated snapshot schedule for track "${trackId}" to ${schedule.mode}`,
);
return { snapshot_schedule: updated.snapshot_schedule };
};

/**
* Create a new virtual snapshot by resolving the composition rules.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ describe('Virtual release-track snapshot schedule validation API', function () {
return response.body.data[0];
}

async function updateSchedule(trackId, snapshotSchedule, status = 200) {
return request(app)
.put(`/api/release-tracks/${trackId}/virtual/schedule`)
.send(snapshotSchedule)
.set('Accept', 'application/json')
.set('Cookie', `${passportCookie.name}=${passportCookie.value}`)
.expect(status);
}

it('accepts and persists the fields defined by each schedule mode', async function () {
const schedules = [
{ mode: 'manual' },
Expand All @@ -69,6 +78,65 @@ describe('Virtual release-track snapshot schedule validation API', function () {
}
});

it('updates a virtual track schedule without creating a snapshot', async function () {
const created = await createTrack({ mode: 'manual' });
const trackId = created.body.id;
const schedule = { mode: 'cron', cron: '15 9 * * 1,3' };

const response = await updateSchedule(trackId, schedule);

expect(response.body.snapshot_schedule).toEqual(schedule);
const registryTrack = await getRegistryTrack(created.name);
expect(registryTrack.snapshot_schedule).toEqual(schedule);
expect(registryTrack.snapshot_count).toBe(1);
});

it('returns the current registry schedule with workbench snapshots', async function () {
const created = await createTrack({ mode: 'manual' });
const schedule = {
mode: 'dates',
dates: ['2027-01-15T09:30:00.000Z', '2027-07-15T09:30:00.000Z'],
};
await updateSchedule(created.body.id, schedule);

const response = await request(app)
.get(`/api/release-tracks/${created.body.id}/snapshots/latest`)
.set('Accept', 'application/json')
.set('Cookie', `${passportCookie.name}=${passportCookie.value}`)
.expect(200);

expect(response.body.snapshot_schedule).toEqual(schedule);
});

it('replaces schedule mode fields instead of retaining stale selectors', async function () {
const created = await createTrack({ mode: 'cron', cron: '0 0 * * *' });

const response = await updateSchedule(created.body.id, { mode: 'manual' });

expect(response.body.snapshot_schedule).toEqual({ mode: 'manual' });
expect(await getRegistryTrack(created.name)).toEqual(
expect.objectContaining({ snapshot_schedule: { mode: 'manual' } }),
);
});

it('rejects invalid updates and schedule updates on standard tracks', async function () {
const virtual = await createTrack({ mode: 'manual' });
const standard = await createTrack(undefined, 201, 'standard');

await updateSchedule(virtual.body.id, { mode: 'cron' }, 400);
await updateSchedule(virtual.body.id, { mode: 'manual', cron: '0 0 * * *' }, 400);
await updateSchedule(standard.body.id, { mode: 'manual' }, 400);
await updateSchedule(
'release-track--11111111-1111-4111-8111-111111111111',
{
mode: 'manual',
},
404,
);

expect(() => releaseTracksService.updateSchedule(virtual.body.id, { mode: 'cron' })).toThrow();
});

it('rejects fields that do not apply to manual schedules', async function () {
const invalidSchedules = [
{ mode: 'manual', cron: '0 0 1 1,7 *' },
Expand Down
5 changes: 5 additions & 0 deletions docs/admin/virtual-track-schedules.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ processed after startup.
`manual` schedules register no executable work. Operators must call
`POST /api/release-tracks/:id/virtual/snapshots/create`.

Editors can replace the active schedule through
`PUT /api/release-tracks/:id/virtual/schedule`. The change is visible
immediately in track and Workbench-format snapshot responses; executable jobs
are refreshed on the next `VIRTUAL_TRACK_SCHEDULES_CRON` reconciliation pass.

## Idempotency and multiple instances

The `virtualTrackScheduleOccurrences` collection stores one durable occurrence
Expand Down
Loading
Loading