@frontera-sdk/cli 1.53.8 → 1.53.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/governed-action-api.ts +9 -3
  3. package/src/blueprint/action-rules.ts +175 -9
  4. package/src/blueprint/diff.ts +27 -8
  5. package/src/blueprint/ontology-edit-plan.ts +155 -4
  6. package/src/blueprint/org-name-check.ts +10 -8
  7. package/src/blueprint/projection.ts +13 -3
  8. package/src/blueprint/property-intents.ts +248 -0
  9. package/src/blueprint/publish-plan.ts +40 -4
  10. package/src/blueprint/render.ts +4 -0
  11. package/src/blueprint/tree.ts +22 -0
  12. package/src/commands/action/deploy.ts +31 -24
  13. package/src/commands/action/retire.ts +9 -1
  14. package/src/commands/action/submit.ts +21 -5
  15. package/src/commands/auth/login.ts +2 -2
  16. package/src/commands/blueprint/authoring.ts +22 -4
  17. package/src/commands/blueprint/declarative.ts +140 -32
  18. package/src/commands/knowledge/index-commands.ts +1 -1
  19. package/src/commands/local/check.ts +6 -4
  20. package/src/commands/local/index-commands.ts +8 -2
  21. package/src/commands/local/sync-request.ts +252 -66
  22. package/src/commands/local/sync.ts +29 -24
  23. package/src/device-login.ts +6 -1
  24. package/src/local/local-actions.ts +13 -1
  25. package/src/local/local-applied.ts +1 -1
  26. package/src/local/local-blueprint-schemas.ts +40 -6
  27. package/src/local/local-dev-references.ts +4 -3
  28. package/src/local/local-jobs.ts +28 -0
  29. package/src/local/local-portability.ts +1 -1
  30. package/src/local/local-sync-link.ts +62 -4
  31. package/src/local/local-sync-merge.ts +37 -1
  32. package/src/local/local-sync-plan.ts +44 -6
  33. package/src/local/local-sync-precheck.ts +2 -1
  34. package/src/local/local-sync-push.ts +22 -11
  35. package/src/local/local-sync-remote.ts +13 -8
  36. package/src/local/local-synthetic-data.ts +2 -2
  37. package/src/main.ts +7 -2
  38. package/src/vendor/sdk-sources.json +2 -2
@@ -9,6 +9,7 @@ import {
9
9
  migrationInstructions,
10
10
  newActionSteps,
11
11
  changedActionSteps,
12
+ needsActionSubjects,
12
13
  publishPlan,
13
14
  } from '../../blueprint/publish-plan'
14
15
  import { signInStep } from '../../device-login'
@@ -375,7 +376,11 @@ export const blueprintPublish: Command = {
375
376
  report = await client.validate()
376
377
  }
377
378
  const signIn = await signInStep({ profile: ctx.profile, apiUrl: ctx.apiUrl })
378
- let plan = publishPlan(report, signIn, profile)
379
+ // An Action switched off by a change to its object type, not to itself, is found by its type.
380
+ const subjects = async (latest: ValidationReport) => (needsActionSubjects(latest)
381
+ ? new Map(((await client.draft()).definition.actions ?? []).map((action) => [action.id, String((action.subject as { objectTypeId?: unknown } | undefined)?.objectTypeId ?? '')]))
382
+ : undefined)
383
+ let plan = publishPlan(report, signIn, profile, await subjects(report))
379
384
 
380
385
  // What `action prepare` records, for each Action that has no decision yet.
381
386
  const prepared: string[] = []
@@ -385,7 +390,8 @@ export const blueprintPublish: Command = {
385
390
  await actions.recordDeploymentDecision(name, `Published with the Frontera CLI ("${label}"): ships switched off until a person deploys it and turns it on.`)
386
391
  prepared.push(name)
387
392
  }
388
- plan = publishPlan(await client.validate(), signIn, profile)
393
+ const revalidated = await client.validate()
394
+ plan = publishPlan(revalidated, signIn, profile, await subjects(revalidated))
389
395
  }
390
396
  const preparedLines = prepared.map((name) => `Recorded that Action ${name} ships switched off: a person deploys it and turns it on after publish.`)
391
397
 
@@ -437,7 +443,12 @@ export const blueprintPublish: Command = {
437
443
  return { data: { release: released, prepared, newActions: [] }, text: `Release "${activeReleaseLabel}" is already active; nothing was published.` }
438
444
  }
439
445
  const newActions = plan.newActions.map((action) => ({ ...action, nextSteps: newActionSteps(action, signIn, profile) }))
440
- const changedActions = plan.changedSwitchedOff.map((action) => ({ apiName: action.apiName, nextSteps: changedActionSteps(action, signIn, profile) }))
446
+ // An Action listed for its type's change is one this publish recorded switched off; one a person had switched off stays as they left it.
447
+ const listed = (action: { apiName: string; objectType?: string }) => !action.objectType || prepared.includes(action.apiName)
448
+ const changedActions = plan.changedSwitchedOff
449
+ .filter(listed)
450
+ .map((action) => ({ apiName: action.apiName, ...(action.objectType ? { objectType: action.objectType } : {}), nextSteps: changedActionSteps(action, signIn, profile) }))
451
+ const leftSwitchedOff = plan.changedSwitchedOff.filter((action) => !listed(action))
441
452
  const numbered = (steps: Array<{ step: string; command?: string }>) => steps.flatMap((step, index) => (step.command ? [` ${index + 1}. ${step.step}:`, ` ${step.command}`] : [` ${index + 1}. ${step.step}.`]))
442
453
  return {
443
454
  data: { release: released, prepared, newActions, changedActions },
@@ -451,9 +462,16 @@ export const blueprintPublish: Command = {
451
462
  ]),
452
463
  ...changedActions.flatMap((action) => [
453
464
  '',
454
- `Action ${action.apiName} changed and is published switched off; its old write path does not run the new version. To turn it on again:`,
465
+ action.objectType
466
+ ? `Action ${action.apiName} acts on ${action.objectType}, which this release changes, and is published switched off; its old write path does not run it. To turn it on again:`
467
+ : `Action ${action.apiName} changed and is published switched off; its old write path does not run the new version. To turn it on again:`,
455
468
  ...numbered(action.nextSteps),
456
469
  ]),
470
+ ...leftSwitchedOff.flatMap((action) => [
471
+ '',
472
+ `Action ${action.apiName} acts on ${action.objectType}, which this release changes, and was switched off before this publish, so it stays switched off. `
473
+ + 'To run it on this release, `frontera action list` shows what it needs.',
474
+ ]),
457
475
  ].join('\n'),
458
476
  }
459
477
  },
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
 
4
4
  import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
@@ -48,6 +48,7 @@ import {
48
48
  type DefinitionBundle,
49
49
  } from '../../blueprint/model'
50
50
  import { toFiles } from '../../blueprint/projection'
51
+ import { namesOf, objectSetNamesOf, readPropertyIntents } from '../../blueprint/property-intents'
51
52
  import { sourceSystemHint } from '../../local/local-blueprint-errors'
52
53
  import { inheritedFromPull, unchangedSincePull } from '../../local/local-check-inherited'
53
54
  import { localProject } from '../../local/local-project'
@@ -63,7 +64,7 @@ import {
63
64
  type ScaffoldOptions,
64
65
  type SemanticTypeTemplate,
65
66
  } from '../../blueprint/scaffold'
66
- import { pathFor, readTree, removeFromTree, writeTree } from '../../blueprint/tree'
67
+ import { pathFor, readObjectSets, readTree, removeFromTree, serialize, writeTree } from '../../blueprint/tree'
67
68
  import { CliError } from '../../errors'
68
69
  import type { Command, CommandContext } from '../types'
69
70
 
@@ -361,9 +362,9 @@ async function computePlan(ctx: CommandContext): Promise<{
361
362
  hint: 'frontera blueprint pull, or frontera blueprint new object-type <ApiName>',
362
363
  })
363
364
  }
364
- const files = readTree(directory)
365
+ const authored = readTree(directory)
365
366
  // A measure's filters the draft would refuse: said by `plan`, and before `apply` writes anything.
366
- const metricProblems = metricRuleProblems(files)
367
+ const metricProblems = metricRuleProblems(authored)
367
368
  if (metricProblems.length) {
368
369
  throw new CliError(`${metricProblems.length} metric problem${metricProblems.length === 1 ? '' : 's'} the draft would refuse, so nothing was planned or applied:\n${metricProblems.map((p) => ` ✕ ${p}`).join('\n')}`, {
369
370
  code: 'VALIDATION_ERROR',
@@ -372,8 +373,11 @@ async function computePlan(ctx: CommandContext): Promise<{
372
373
  })
373
374
  }
374
375
  const { definition, revision } = await client.draft()
376
+ // Compared against the draft as it reads once the files' renames are made, so a
377
+ // renamed property, and every file naming it, plans as the same property.
378
+ const { files, live, ...intents } = readPropertyIntents(authored, definition, readObjectSets(directory))
375
379
  const datasets = await readDatasetIndex(client, files, definition)
376
- const plan = withheldPrunes(diff(files, definition, revision, datasets), directory)
380
+ const plan = withheldPrunes(diff(files, live, revision, datasets, intents), directory)
377
381
 
378
382
  // A type moving to another Dataset is no longer held to the old one's contract.
379
383
  const rebound = new Set(plan.rebind.map((change) => change.objectApiName))
@@ -635,6 +639,7 @@ export async function applyOperation(
635
639
  )
636
640
  current = nextRevision(updated, revision)
637
641
  }
642
+ current = await applyPropertyRenames(client, operation.apiName, operation.renames ?? [], current)
638
643
  current = await applyObjectKeys(client, operation.apiName, file, operation.liveDocument, current)
639
644
  current = await applyPropertyPlan(
640
645
  client, operation.apiName, file, operation.liveDocument, current, ids,
@@ -1012,10 +1017,8 @@ export function objectTypeMetadataChanges(
1012
1017
  * planning tool that says it did something it did not is the one failure worth
1013
1018
  * refusing outright, so anything this cannot map now aborts BEFORE the first write.
1014
1019
  *
1015
- * Mapped: add, remove, and a change to any field the file can state. Unmapped: a
1016
- * property rename, which is indistinguishable from a remove plus an add for the same
1017
- * reason an object-type rename is (`rename` exists for that, and there is no
1018
- * per-property equivalent).
1020
+ * Mapped: add, remove, and a change to any field the file can state. A rename is
1021
+ * made before this runs (`applyPropertyRenames`), so here it is the same property.
1019
1022
  */
1020
1023
  interface PropertyPlan {
1021
1024
  adds: Array<Record<string, unknown>>
@@ -1145,6 +1148,34 @@ function planProperties(
1145
1148
  return plan
1146
1149
  }
1147
1150
 
1151
+ /**
1152
+ * A property that keeps its id and takes the name the file gives it (`renamedFrom`).
1153
+ * Before the keys and the field commands, which name properties as the file does.
1154
+ */
1155
+ async function applyPropertyRenames(
1156
+ client: BlueprintAuthoringApi,
1157
+ apiName: string,
1158
+ renames: ReadonlyArray<{ from: string; to: string }>,
1159
+ revision: number,
1160
+ ): Promise<number> {
1161
+ if (renames.length === 0) return revision
1162
+ const detail = await client.getObjectTypeDetail(apiName)
1163
+ let current = revision
1164
+ for (const { from, to } of renames) {
1165
+ const fieldId = detail?.properties.find((property) => property.apiName === from)?.id
1166
+ if (!detail?.id || !fieldId) {
1167
+ throw new CliError(`"${apiName}.${from}" is no longer on the draft.`, {
1168
+ code: 'CONFLICT',
1169
+ hint: 'frontera blueprint plan — the draft moved under this run.',
1170
+ })
1171
+ }
1172
+ current = (await client.draftCommand({
1173
+ kind: 'update_field_schema', objectId: detail.id, fieldId, patch: { apiName: to },
1174
+ }, current)).revision
1175
+ }
1176
+ return current
1177
+ }
1178
+
1148
1179
  async function applyPropertyPlan(
1149
1180
  client: BlueprintAuthoringApi,
1150
1181
  apiName: string,
@@ -1188,24 +1219,6 @@ async function applyPropertyPlan(
1188
1219
  }
1189
1220
  }
1190
1221
 
1191
- // A property that leaves AND one that arrives in the same edit is the shape a
1192
- // rename takes, and it is indistinguishable from a genuine replacement — the two
1193
- // have opposite consequences for the data behind the field. The earlier guard only
1194
- // caught it when `backing.mapping` lacked the new name; update the mapping too and
1195
- // it silently ran remove_field + add_field, which is the data drop the refusal was
1196
- // supposed to prevent.
1197
- if (plan.adds.length > 0 && plan.removes.length > 0) {
1198
- throw new CliError(
1199
- `${file.path}: this edit removes ${plan.removes.map((entry) => entry.apiName).join(', ')} and adds `
1200
- + `${plan.adds.map((entry) => String(entry.apiName)).join(', ')} at once.`,
1201
- {
1202
- code: 'USAGE',
1203
- hint: 'If that is a rename, there is no per-property rename command — do the removal and the '
1204
- + 'addition in separate applies, so the data consequence is a decision rather than a side effect.',
1205
- },
1206
- )
1207
- }
1208
-
1209
1222
  let current = revision
1210
1223
  for (const property of plan.adds) {
1211
1224
  const name = String(property.apiName)
@@ -1376,6 +1389,13 @@ export const blueprintApply: Command = {
1376
1389
  const prune = ctx.flags.prune === true
1377
1390
  const { plan, files, definition, drift, actionProblems, inheritedWrites } = await computePlan(ctx)
1378
1391
  assertActionsApplicable(actionProblems, plan.operations)
1392
+ if (plan.propertyRefusals?.length) {
1393
+ // Said as a failed write says it, so `local dev` leaves that type off and applies the rest (`refusedArtifact`).
1394
+ throw new CliError(`Nothing was applied.\nStopped at: update object-type ${plan.propertyRefusals[0]!.objectApiName}\n${plan.propertyRefusals.map((refusal) => refusal.message).join('\n')}`, {
1395
+ code: 'USAGE',
1396
+ hint: plan.propertyRefusals.map((refusal) => refusal.next).join('\n'),
1397
+ })
1398
+ }
1379
1399
  const actionWarnings = renderActionProblems({ refused: [], undeployable: actionProblems.undeployable, refusedActions: [], ...(actionProblems.inherited ? { inherited: actionProblems.inherited } : {}) })
1380
1400
 
1381
1401
  // BEFORE the first write, not before the prune loop: the operations land first,
@@ -1858,15 +1878,16 @@ export const blueprintRename: Command = {
1858
1878
  noun: 'blueprint',
1859
1879
  verb: 'rename',
1860
1880
  args: [
1861
- { name: 'kind', required: true, description: 'object-type' },
1862
- { name: 'from', required: true, description: 'Current apiName' },
1881
+ { name: 'kind', required: true, description: 'object-type or property' },
1882
+ { name: 'from', required: true, description: 'Current apiName (a property: its object type, then its current apiName)' },
1863
1883
  { name: 'to', required: true, description: 'New apiName' },
1864
1884
  ],
1865
1885
  flags: { dir: 'string' },
1866
- summary: 'Rename an object type, and its file, as one act',
1867
- examples: ['frontera blueprint rename object-type Client Customer'],
1886
+ summary: 'Rename an object type or a property, and the files that name it, as one act',
1887
+ examples: ['frontera blueprint rename object-type Client Customer', 'frontera blueprint rename property Ticket assignee owner'],
1868
1888
  },
1869
1889
  async run(ctx) {
1890
+ if (ctx.positional[0] === 'property') return renameProperty(ctx)
1870
1891
  const [kind, from, to] = ctx.positional
1871
1892
  if (kind === 'object-type' && to && !API_NAME_RULE['object-type'].test(to)) {
1872
1893
  throw new CliError(`"${to}" is not a valid object-type apiName.`, {
@@ -1875,7 +1896,7 @@ export const blueprintRename: Command = {
1875
1896
  })
1876
1897
  }
1877
1898
  if (kind !== 'object-type') {
1878
- throw new CliError('Only object types can be renamed in place.', {
1899
+ throw new CliError('Only object types and properties can be renamed in place.', {
1879
1900
  code: 'USAGE',
1880
1901
  // Being specific about the alternative rather than just refusing: for the
1881
1902
  // other kinds a delete-and-create is genuinely equivalent, because nothing
@@ -1929,6 +1950,93 @@ export const blueprintRename: Command = {
1929
1950
  },
1930
1951
  }
1931
1952
 
1953
+ /**
1954
+ * `rename property <ObjectType> <from> <to>`: the same property under a new name,
1955
+ * on the draft and in the files. The property keeps its id, so its column, its
1956
+ * edited values and every Action, link and metric that names it stay with it, and
1957
+ * the files that name it, saved object sets included, are changed to the new name. The type file keeps
1958
+ * `renamedFrom: <from>` on it, which is how an apply or a change request against
1959
+ * another draft that still has the old name renames it the same way.
1960
+ */
1961
+ async function renameProperty(ctx: CommandContext) {
1962
+ const [, objectApiName, from, to] = ctx.positional
1963
+ if (!objectApiName || !from || !to) {
1964
+ throw new CliError('A property rename names its object type, its current apiName and its new one.', {
1965
+ code: 'USAGE',
1966
+ hint: 'frontera blueprint rename property <ObjectType> <from> <to>',
1967
+ })
1968
+ }
1969
+ if (!/^[a-z][A-Za-z0-9]{0,99}$/.test(to)) {
1970
+ throw new CliError(`"${to}" is not a valid property apiName.`, { code: 'USAGE', hint: 'camelCase: a lowercase letter, then letters and digits, up to 100.' })
1971
+ }
1972
+ const client = api(ctx)
1973
+ const detail = await client.getObjectTypeDetail(objectApiName)
1974
+ if (!detail?.id) {
1975
+ throw new CliError(`No object type "${objectApiName}" on the draft.`, { code: 'NOT_FOUND', hint: 'frontera blueprint catalog' })
1976
+ }
1977
+ const names = new Set(detail.properties.map((property) => property.apiName))
1978
+ const field = detail.properties.find((property) => property.apiName === from)
1979
+ if (field && names.has(to)) {
1980
+ throw new CliError(`${objectApiName} has both ${from} and ${to}, so ${from} cannot take the name.`, {
1981
+ code: 'CONFLICT',
1982
+ hint: `remove ${to} first (its own apply), or rename ${from} to another name`,
1983
+ })
1984
+ }
1985
+ if (!field && !names.has(to)) {
1986
+ throw new CliError(`${objectApiName} has no property "${from}" on the draft.`, { code: 'NOT_FOUND', hint: `frontera blueprint get object-type ${objectApiName}` })
1987
+ }
1988
+ if (field?.id) {
1989
+ await client.draftCommand(
1990
+ { kind: 'update_field_schema', objectId: detail.id, fieldId: field.id, patch: { apiName: to } },
1991
+ await client.revision(),
1992
+ )
1993
+ }
1994
+
1995
+ const directory = root(ctx)
1996
+ const files = existsSync(directory) ? readTree(directory) : []
1997
+ const changed = new Map<string, AuthoredFile>()
1998
+ const changedSets: string[] = []
1999
+ const typeFile = files.find((file) => file.kind === 'object-type' && file.apiName === objectApiName)
2000
+ if (typeFile) {
2001
+ const properties = Array.isArray(typeFile.document.properties) ? typeFile.document.properties as Array<Record<string, unknown>> : []
2002
+ const target = properties.find((property) => property.apiName === to) ?? properties.find((property) => property.apiName === from)
2003
+ if (target && (target.apiName !== to || target.renamedFrom !== from)) {
2004
+ target.apiName = to
2005
+ target.renamedFrom = from
2006
+ changed.set(typeFile.path, typeFile)
2007
+ }
2008
+ // A property the file still names `from` is a new one taking the old name back,
2009
+ // and what the files name `from` then means it.
2010
+ if (!properties.some((property) => property.apiName === from)) {
2011
+ for (const file of files) {
2012
+ const places = namesOf(file, objectApiName, from)
2013
+ for (const rename of places) rename(to)
2014
+ if (places.length) changed.set(file.path, file)
2015
+ }
2016
+ const sets = readObjectSets(directory)
2017
+ for (const set of sets) {
2018
+ const places = objectSetNamesOf(set, files, sets, objectApiName, from)
2019
+ for (const rename of places) rename(to)
2020
+ if (places.length) {
2021
+ writeFileSync(join(directory, set.path), serialize(set.path, set.document))
2022
+ changedSets.push(set.path)
2023
+ }
2024
+ }
2025
+ }
2026
+ }
2027
+ if (changed.size) writeTree(directory, [...changed.values()])
2028
+ const changedPaths = [...changed.keys(), ...changedSets]
2029
+ const where = changedPaths.length ? ` Changed ${changedPaths.join(', ')}.` : ''
2030
+ return {
2031
+ data: { objectType: objectApiName, from, to, renamedOnDraft: Boolean(field), changedFiles: changedPaths },
2032
+ text: (field
2033
+ ? `Renamed ${objectApiName}.${from} to ${to} on the draft. It keeps its id, so its column, its edited values and what names it stay with it.`
2034
+ : `${objectApiName}.${from} is already ${to} on the draft.`)
2035
+ + where
2036
+ + '\nNext: `frontera blueprint apply --yes` applies the rest of the files, then `frontera blueprint publish` releases it.',
2037
+ }
2038
+ }
2039
+
1932
2040
  export const blueprintDeclarativeCommands: Command[] = [
1933
2041
  blueprintPull,
1934
2042
  blueprintPlan,
@@ -34,7 +34,7 @@ interface KnowledgeRow {
34
34
  */
35
35
  async function knowledgeAppNote(api: PlatformApi): Promise<string> {
36
36
  return (await api.knowledgeAppRuns())
37
- ? ' A knowledge base from the Knowledge app is not one of these: it runs on the platform only, not on a local runtime. '
37
+ ? ' A knowledge base from the Knowledge app is not one of these: it runs on the platform only, with its search and AI answers, not on a local runtime. '
38
38
  + 'Choose it for an Agent on the platform, under the Agent\'s Knowledge page, and test that Agent there.'
39
39
  : ''
40
40
  }
@@ -28,6 +28,7 @@ import { findWorkflowTestFiles, recordWorkflowTestRun, runWorkflowTests, workflo
28
28
  import { vendoredSdkLine, vendoredSdkState } from '../../local/local-vendored-sdk'
29
29
  import { withFix } from '../../local/fix-words'
30
30
  import { runtimeKeyCustodianProblem } from '../../local/local-key-custodian'
31
+ import { runtimeJobsProblem } from '../../local/local-jobs'
31
32
  import { ensureAppPackages } from '../../project-bootstrap'
32
33
  import { flagBool, flagString, type Command, type CommandContext } from '../types'
33
34
 
@@ -395,10 +396,11 @@ async function check(ctx: CommandContext) {
395
396
  }
396
397
  if (step === 'start') {
397
398
  if (running(project)) {
398
- // Running, with keys it can't read: every later step would fail on a secret or an Action record.
399
- const state = readState(project)!
400
- const keyProblem = await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`)
401
- if (keyProblem) record({ step, status: 'failed', note: keyProblem, ms: 0 })
399
+ // Running, with keys it can't read (every later step would fail on a secret or an Action record),
400
+ // or with a jobs engine that delivers nothing (every later step that writes waits on it).
401
+ const service = `http://127.0.0.1:${readState(project)!.ports.service}`
402
+ const problem = (await runtimeKeyCustodianProblem(service)) ?? (await runtimeJobsProblem(service))
403
+ if (problem) record({ step, status: 'failed', note: problem, ms: 0 })
402
404
  else record({ step, status: 'skipped', note: 'the runtime is already running', ms: 0 })
403
405
  } else await command(step, root, self(['local', 'start']), 'frontera local start')
404
406
  continue
@@ -40,7 +40,7 @@ import { agentFilesProblem, caseConcurrency, caseCount, caseRepeat, DEFAULT_PASS
40
40
  import { compareWithRun, comparisonWords, listTestRuns, readTestRun, runOnModel, saveTestRun, stuckWords } from '../../local/local-test-models'
41
41
  import { modelSubstitutions, skippedCases, skippedWords } from '../../local/local-test-scope'
42
42
  import { devServerLine, listDevServers, stopDevServers } from '../../mcp/mcp-apps'
43
- import { restartJobs } from '../../local/local-jobs'
43
+ import { JOBS_STALL_FIX, restartJobs, runtimeJobsProblem } from '../../local/local-jobs'
44
44
  import { conversationTranscript } from '../../mcp/mcp-conversations'
45
45
  import { runtimeLink } from '../../mcp/mcp-runtime'
46
46
  import { caseTraces, evidenceLine, failingCaseEvidence } from '../../local/local-case-evidence'
@@ -470,7 +470,11 @@ async function status(ctx: CommandContext) {
470
470
  // What the runtime holds with no file in the project (an Agent whose folder was deleted): said with its fix.
471
471
  const orphans = (state.phase === 'ready' && isAlive(state.supervisorPid) && state.credentials ? await projectOrphans(project, `http://127.0.0.1:${state.ports.service}`, state.credentials).catch(() => null) : null) ?? []
472
472
  // Encryption keys the runtime can't read (data keys a hosted OpenBao locked): every secret and governed Action record fails until a reset.
473
- const keyProblem = state.phase === 'ready' && isAlive(state.supervisorPid) ? await runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`) : null
473
+ // And governed work the jobs engine has left waiting: its port can answer while its calls into the service fail, so its line above says running.
474
+ // Read together: each waits up to 3 s on a service that doesn't answer.
475
+ const [keyProblem, jobsProblem] = state.phase === 'ready' && isAlive(state.supervisorPid)
476
+ ? await Promise.all([runtimeKeyCustodianProblem(`http://127.0.0.1:${state.ports.service}`), runtimeJobsProblem(`http://127.0.0.1:${state.ports.service}`)])
477
+ : [null, null]
474
478
  // Who last changed the runtime or the files, one line per session, and the run holding the runtime (X9).
475
479
  const writers = recentWriters(project.stateDir)
476
480
  const lock = readLock(project.stateDir)
@@ -495,12 +499,14 @@ async function status(ctx: CommandContext) {
495
499
  // On the runtime, not in the files: each with what fixes it (`local dev` archives an Agent or workflow whose file was deleted).
496
500
  ...(orphans.length ? { notInProject: orphans } : {}),
497
501
  ...(keyProblem ? { keyCustodian: { problem: keyProblem, fix: KEY_CUSTODIAN_FIX } } : {}),
502
+ ...(jobsProblem ? { jobsDelivery: { problem: jobsProblem, fix: JOBS_STALL_FIX } } : {}),
498
503
  writers,
499
504
  ...(lock ? { lock: { command: lock.command, session: lock.session, harness: lock.harness, since: lock.since } } : {}),
500
505
  },
501
506
  text: [
502
507
  renderStatus({ ...state, components }, project, undefined, live, setIds, personas, secrets, liveApps ?? null, down),
503
508
  ...(keyProblem ? ['', 'Encryption keys', ` ✕ ${keyProblem}`] : []),
509
+ ...(jobsProblem ? ['', 'Jobs', ` ✕ ${jobsProblem}`] : []),
504
510
  ...(stuck.length ? ['', 'Knowledge', ...stuck.map((k) => ` warning: ${k.problem}`)] : []),
505
511
  ...(orphans.length ? ['', 'On the runtime, not in the project', ...orphans.map((o) => ` warning: ${orphanLine(o)}`)] : []),
506
512
  ...(devServers.length ? ['', 'App dev servers', ...devServers.map((s) => ` ${devServerLine(s)}`)] : []),