|
| 1 | +import { readFileSync } from 'node:fs'; |
| 2 | +import { fileURLToPath } from 'node:url'; |
| 3 | + |
1 | 4 | import { describe, it, expect } from 'vitest'; |
2 | 5 | import type { IAutomationService, AutomationResult } from './automation-service'; |
3 | 6 | import type { FlowParsed } from '../automation/flow.zod'; |
4 | 7 | import { FlowSchema } from '../automation/flow.zod'; |
5 | 8 | import type { ExecutionLog } from '../automation/execution.zod'; |
6 | 9 | import type { ConnectorDescriptor } from '../integration/connector-descriptor'; |
7 | 10 |
|
| 11 | +/** |
| 12 | + * [#16495] Type-level identities for the two operator verbs (the #14384 pin's |
| 13 | + * form): a change to either signature — a dropped optional parameter, a |
| 14 | + * widened or narrowed result — turns an exported alias red under |
| 15 | + * `check:test-typecheck`, which compiles this file. Exported deliberately: an |
| 16 | + * unread alias inside a test body is TS6196, and a pin no program compiles is |
| 17 | + * no pin at all. |
| 18 | + */ |
| 19 | +type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; |
| 20 | +type Assert<T extends true> = T; |
| 21 | +type CancelRun = NonNullable<IAutomationService['cancelRun']>; |
| 22 | +type RestoreConsumedSuspension = NonNullable<IAutomationService['restoreConsumedSuspension']>; |
| 23 | +/** `cancelRun(runId, reason?)` — the engine's shape, not the ruling's `cancelRun(runId)`. */ |
| 24 | +export type CancelRunTakesRunIdAndReason = Assert<Eq<Parameters<CancelRun>, [runId: string, reason?: string]>>; |
| 25 | +export type CancelRunAnswersBoolean = Assert<Eq<ReturnType<CancelRun>, Promise<boolean>>>; |
| 26 | +/** `restoreConsumedSuspension(runId, options?)` — who asked and why travel through the contract. */ |
| 27 | +export type RestoreTakesRunIdAndOptions = Assert< |
| 28 | + Eq<Parameters<RestoreConsumedSuspension>, [runId: string, options?: { requestedBy?: string; reason?: string }]> |
| 29 | +>; |
| 30 | +/** The narrower structural result (route (i)): restored, the id echoed, the refusal code, the one-sentence reason. */ |
| 31 | +export type RestoreAnswersTheNarrowResult = Assert< |
| 32 | + Eq<Awaited<ReturnType<RestoreConsumedSuspension>>, { restored: boolean; runId: string; refusal?: string; reason: string }> |
| 33 | +>; |
| 34 | + |
8 | 35 | describe('Automation Service Contract', () => { |
9 | 36 | it('should allow a minimal IAutomationService implementation with required methods', () => { |
10 | 37 | const service: IAutomationService = { |
@@ -249,4 +276,121 @@ describe('Automation Service Contract', () => { |
249 | 276 |
|
250 | 277 | expect(service.getConnectorDescriptors).toBeUndefined(); |
251 | 278 | }); |
| 279 | + |
| 280 | + // [#16495] The two operator run-lifecycle verbs — the contract half of |
| 281 | + // #13953's ruling A (maintainer 2026-09-05, decision batch #42). The same |
| 282 | + // shape of pin #14384 added for `'stranded'`: the members are declared, |
| 283 | + // OPTIONAL, typed as the engine implements them (not as the ruling's |
| 284 | + // `verb(runId)` shorthand), and their docblocks carry the ruling's |
| 285 | + // persistent-face statement. The type-level identities are the exported |
| 286 | + // aliases above the suite; the compile of the literals below is the rest. |
| 287 | + describe('[#16495] cancelRun / restoreConsumedSuspension — the operator verbs, declared', () => { |
| 288 | + it('are optional: the minimal implementation still conforms and has no operator door', () => { |
| 289 | + const service: IAutomationService = { |
| 290 | + execute: async () => ({ success: true }), |
| 291 | + listFlows: async () => [], |
| 292 | + }; |
| 293 | + |
| 294 | + expect(service.cancelRun).toBeUndefined(); |
| 295 | + expect(service.restoreConsumedSuspension).toBeUndefined(); |
| 296 | + }); |
| 297 | + |
| 298 | + it('carry the engine signatures through the contract — who asked, and why, reach the implementation', async () => { |
| 299 | + const seen: Array<Record<string, unknown>> = []; |
| 300 | + const service: IAutomationService = { |
| 301 | + execute: async () => ({ success: true }), |
| 302 | + listFlows: async () => [], |
| 303 | + cancelRun: async (runId: string, reason?: string): Promise<boolean> => { |
| 304 | + seen.push({ verb: 'cancelRun', runId, reason }); |
| 305 | + return runId === 'run_paused'; |
| 306 | + }, |
| 307 | + restoreConsumedSuspension: async (runId, options) => { |
| 308 | + seen.push({ verb: 'restoreConsumedSuspension', runId, ...options }); |
| 309 | + return runId === 'run_stranded' |
| 310 | + ? { restored: true, runId, reason: `Run '${runId}' is suspended again at node 'approve'` } |
| 311 | + : { restored: false, runId, refusal: 'RUN_NOT_FOUND', reason: `No run '${runId}' is known` }; |
| 312 | + }, |
| 313 | + }; |
| 314 | + |
| 315 | + expect(await service.cancelRun!('run_paused', 'submitter withdrew the request')).toBe(true); |
| 316 | + // No suspended run under the id ⇒ `false`: idempotent success, not a throw. |
| 317 | + expect(await service.cancelRun!('run_gone')).toBe(false); |
| 318 | + |
| 319 | + const restored = await service.restoreConsumedSuspension!('run_stranded', { |
| 320 | + requestedBy: 'ops@example.com', |
| 321 | + reason: 'notify node fixed; the approval will be re-issued', |
| 322 | + }); |
| 323 | + expect(restored.restored).toBe(true); |
| 324 | + expect(restored.runId).toBe('run_stranded'); |
| 325 | + // `refusal` is absent exactly when `restored` is `true`. |
| 326 | + expect(restored.refusal).toBeUndefined(); |
| 327 | + expect(restored.reason).toContain('run_stranded'); |
| 328 | + |
| 329 | + const refused = await service.restoreConsumedSuspension!('run_gone'); |
| 330 | + expect(refused.restored).toBe(false); |
| 331 | + expect(refused.refusal).toBe('RUN_NOT_FOUND'); |
| 332 | + // `reason` is present both ways — the operator is told what was observed. |
| 333 | + expect(refused.reason).toBe("No run 'run_gone' is known"); |
| 334 | + |
| 335 | + // The optional parameters ARE the reason the signatures follow the |
| 336 | + // engine: a door calling through the contract can say who asked and why. |
| 337 | + expect(seen).toEqual([ |
| 338 | + { verb: 'cancelRun', runId: 'run_paused', reason: 'submitter withdrew the request' }, |
| 339 | + { verb: 'cancelRun', runId: 'run_gone', reason: undefined }, |
| 340 | + { |
| 341 | + verb: 'restoreConsumedSuspension', |
| 342 | + runId: 'run_stranded', |
| 343 | + requestedBy: 'ops@example.com', |
| 344 | + reason: 'notify node fixed; the approval will be re-issued', |
| 345 | + }, |
| 346 | + { verb: 'restoreConsumedSuspension', runId: 'run_gone' }, |
| 347 | + ]); |
| 348 | + }); |
| 349 | + |
| 350 | + it('refuse a restore result that omits the always-present `reason` (compile-time, under check:test-typecheck)', () => { |
| 351 | + const service: IAutomationService = { |
| 352 | + execute: async () => ({ success: true }), |
| 353 | + listFlows: async () => [], |
| 354 | + // @ts-expect-error — `reason` is required both ways: an operator whose repair was refused must be told what was observed. |
| 355 | + restoreConsumedSuspension: async (runId) => ({ restored: false, runId, refusal: 'RUN_NOT_FOUND' }), |
| 356 | + }; |
| 357 | + |
| 358 | + expect(service.restoreConsumedSuspension).toBeDefined(); |
| 359 | + }); |
| 360 | + |
| 361 | + it('the docblocks carry the persistent-face statement, the ruled permission posture, and the no-door-when-absent rule', () => { |
| 362 | + const source = readFileSync(fileURLToPath(new URL('./automation-service.ts', import.meta.url)), 'utf8'); |
| 363 | + const docAbove = (declaration: string): string => { |
| 364 | + const at = source.indexOf(declaration); |
| 365 | + expect(at).toBeGreaterThan(-1); |
| 366 | + // The doc block immediately above the declaration — from its last `/**`. |
| 367 | + return source.slice(source.lastIndexOf('/**', at), at); |
| 368 | + }; |
| 369 | + const cancel = docAbove('cancelRun?(runId: string, reason?: string): Promise<boolean>;'); |
| 370 | + const restore = docAbove('restoreConsumedSuspension?('); |
| 371 | + |
| 372 | + for (const doc of [cancel, restore]) { |
| 373 | + // The ruling's persistent-face sentence, in its own words. |
| 374 | + expect(doc).toContain('listing and acting go through `sys_automation_run`'); |
| 375 | + expect(doc).toMatch(/never engine memory/); |
| 376 | + // The ruled permission posture, so the door does not invent one. |
| 377 | + expect(doc).toContain('`platform_admin`'); |
| 378 | + expect(doc).toMatch(/no new permission type, no[\s*]+per-run ownership/); |
| 379 | + // Optional ⇒ absent means no door, and the door refuses fail-closed. |
| 380 | + expect(doc).toMatch(/NO[\s*]+operator door/); |
| 381 | + expect(doc).toMatch(/refuse[\s*]+fail-closed/); |
| 382 | + } |
| 383 | + // Cancel: `false` is idempotent success — and an unreadable store lands there too. |
| 384 | + expect(cancel).toMatch(/idempotent/); |
| 385 | + expect(cancel).toMatch(/could[\s*]+not[\s*]+READ/); |
| 386 | + // Restore: the trace records who asked and why (the reason the signature |
| 387 | + // follows the engine), and the two things an operator must know. |
| 388 | + expect(restore).toContain('not recorded'); |
| 389 | + expect(restore).toMatch(/NOT[\s*]+replayed/); |
| 390 | + expect(restore).toMatch(/NOT[\s*]+undone/); |
| 391 | + // Restore: the narrower-result decision is stated where it is read. |
| 392 | + expect(restore).toContain('SuspensionRestoreResult'); |
| 393 | + expect(restore).toMatch(/route \(i\)/); |
| 394 | + }); |
| 395 | + }); |
252 | 396 | }); |
0 commit comments