M3(2/5): human-tool 与 release —— 卡协议与放行门
human-tool(L3): - 三规矩机检:调用必附上下文(裸调用拒发)/ 回传即留痕 / 完成只出自放行通过 - 工单两模式 + 对话卡族的形态校验与回传校验;驳回必附意见;补全卡「没法填」旁路; 决策卡自定义收尾项必附理由;已答不得再答(只增不改) - 到达面两路(负责人 → 任务对话流;他人 → 系统消息);驳回重答 = 新卡实例、原卡闭合 - 调人预算由基座在环外强制;落地失败卡 = 执行工单的 preset release(L4): - 落地预检五项(入参 schema / 工具可用与授权 / 连接可达 / 目标预校验 / 数据冲突) - 预检全过才发验收工单,卡面即已验证的动作清单;不过即打回环内,不发卡 - 放行写合并 + 幂等落地(任务 × 意向);落地收敛才发射输出口; 受阻走落地失败卡且 emittedPorts 恒空;已成动作留存不回滚 215 个测试通过,门禁五道全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
146e9bbfad
commit
561a2474ae
@@ -0,0 +1,31 @@
|
||||
# module: release
|
||||
|
||||
职责: 放行与落地 —— 落地预检五项、动作意向登记、放行写合并、幂等落地、
|
||||
落地失败卡、输出口发射
|
||||
不负责: 放行卡的卡协议与回传校验(见 human-tool)、草稿层与冲突检测的实现(见 ledger)、
|
||||
工具执行的实现(见 toolbox)、终态命名(见 task)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`ReleaseService` `PrecheckReport` `PrecheckFinding` `LandingVerdict` `LandOutcome`
|
||||
`SubmitForReleaseInput` `SubmitOutcome` `DeliverableCheck` `CriterionCheck`
|
||||
- 错误:`PrecheckFailedError` `DeliverablesMissingError`
|
||||
- 端口类型:`IntentStore` `LedgerGateway` `ToolGateway` `CardGateway` `TaskContext`
|
||||
`PortEmitter` `MemoryRecorder`
|
||||
- 用例工厂:`createReleaseService(deps): ReleaseService`
|
||||
- 纯函数:`landingVerdict` `missingDeliverables` `submittable` `summarize` `idempotencyKey`
|
||||
- adapter:`createIntentStore`;Drizzle 表:`actionIntentTable`
|
||||
|
||||
层级: L4(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;port 注入:ledger(L1)、toolbox(L2)、human-tool(L3)、
|
||||
event(L1,输出口发射)、memory(L2)
|
||||
被依赖: loop-engine, flow-engine, task, app
|
||||
|
||||
不变量(OS 7.3 / AM 12.1):
|
||||
- **预检先于放行**:`submitForRelease` 预检不过即抛 `PrecheckFailedError`,**不发卡**
|
||||
- **卡面即已验证的动作清单**:验收工单的 actions 只列预检通过的意向
|
||||
- **必交付物缺一不得提交放行**
|
||||
- **放行通过即刻落地**,每个动作附幂等键(任务 × 意向)—— 重放不重复触达世界
|
||||
- **落地收敛,输出口才发射** —— 下游只见落地完成的任务;受阻时 `emittedPorts` 恒为空
|
||||
- **已成动作留存不回滚**;残余失败走落地失败卡(执行工单的预置形态),不静默成功
|
||||
- **落地受阻是运行态不是终态**:处置后仍收敛于终态名录
|
||||
- 放行写经 `ledger` 出口的 `releaseWrite`,不自行碰台账(§12.5#9)
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 落地预检与落地收敛 —— domain 测试零 mock(OS 7.3) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
idempotencyKey,
|
||||
landingVerdict,
|
||||
missingDeliverables,
|
||||
submittable,
|
||||
summarize,
|
||||
} from '../internal/domain/precheck.ts';
|
||||
import type { CriterionCheck, LandingOutcome } from '../internal/domain/precheck.ts';
|
||||
|
||||
describe('summarize —— 预检全过才可发卡', () => {
|
||||
it('无 finding 即通过', () => {
|
||||
const report = summarize([], [{ id: 'i1', label: '发报价邮件', effect: 'write', detail: '' }]);
|
||||
expect(report.passed).toBe(true);
|
||||
expect(report.verified).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('任一 finding 即不通过', () => {
|
||||
const report = summarize([{ item: 'data-conflict', message: '字段已被他单改动' }], []);
|
||||
expect(report.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('必交付物缺一不得提交放行', () => {
|
||||
it('列出缺失项', () => {
|
||||
const missing = missingDeliverables([
|
||||
{ key: 'a', label: '统一受理记录', present: true },
|
||||
{ key: 'b', label: '料号分类表', present: false },
|
||||
]);
|
||||
expect(missing.map((m) => m.key)).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submittable —— 判据全绿只是可提交', () => {
|
||||
const base: CriterionCheck[] = [
|
||||
{ key: 'a', label: '八字段齐', level: 'L1', pass: true },
|
||||
{ key: 'b', label: '偏离度合规', level: 'L2', pass: true },
|
||||
];
|
||||
|
||||
it('L1/L2 全绿即可提交', () => {
|
||||
expect(submittable(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('L2 未过即不可提交', () => {
|
||||
expect(submittable([...base.slice(0, 1), { key: 'b', label: 'x', level: 'L2', pass: false }])).toBe(false);
|
||||
});
|
||||
|
||||
it('L5 是责任关,不参与可提交判定 —— 认账永远是人的动作', () => {
|
||||
expect(submittable([...base, { key: 'c', label: '人放行', level: 'L5', pass: false }])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('landingVerdict —— 落地收敛', () => {
|
||||
const ok: LandingOutcome = { id: 'i1', ok: true, retryable: false };
|
||||
const bad: LandingOutcome = { id: 'i2', ok: false, error: '连接超时', retryable: true };
|
||||
|
||||
it('全成即收敛', () => {
|
||||
expect(landingVerdict([ok])).toEqual({ kind: 'converged' });
|
||||
});
|
||||
|
||||
it('零动作也算收敛', () => {
|
||||
expect(landingVerdict([])).toEqual({ kind: 'converged' });
|
||||
});
|
||||
|
||||
it('有失败即落地受阻,并带出失败清单', () => {
|
||||
const verdict = landingVerdict([ok, bad]);
|
||||
expect(verdict.kind).toBe('blocked');
|
||||
if (verdict.kind === 'blocked') {
|
||||
expect(verdict.failures.map((f) => f.id)).toEqual(['i2']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotencyKey —— 重放不重复触达世界', () => {
|
||||
it('任务 × 意向', () => {
|
||||
expect(idempotencyKey('t1', 'i1')).toBe('t1:i1');
|
||||
expect(idempotencyKey('t1', 'i1')).toBe(idempotencyKey('t1', 'i1'));
|
||||
expect(idempotencyKey('t1', 'i1')).not.toBe(idempotencyKey('t2', 'i1'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* module: release —— 对外类型(编码规范 §10.1)。
|
||||
*
|
||||
* 放行门:草稿态 → 事实态的**唯一通道**。门归系统,启门归人(OS 10)。
|
||||
*/
|
||||
import type { ActionIntent, StaffId, TaskId } from '@contracts';
|
||||
import type {
|
||||
CriterionCheck,
|
||||
DeliverableCheck,
|
||||
LandingOutcome,
|
||||
LandingVerdict,
|
||||
PrecheckFinding,
|
||||
PrecheckItem,
|
||||
PrecheckReport,
|
||||
} from './internal/domain/precheck.ts';
|
||||
|
||||
export type {
|
||||
ActionIntent,
|
||||
CriterionCheck,
|
||||
DeliverableCheck,
|
||||
LandingOutcome,
|
||||
LandingVerdict,
|
||||
PrecheckFinding,
|
||||
PrecheckItem,
|
||||
PrecheckReport,
|
||||
};
|
||||
|
||||
export class PrecheckFailedError extends Error {
|
||||
constructor(readonly report: PrecheckReport) {
|
||||
super(`落地预检未过,不发放行卡:${report.findings.map((f) => f.message).join('; ')}`);
|
||||
this.name = 'PrecheckFailedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DeliverablesMissingError extends Error {
|
||||
constructor(readonly missing: readonly DeliverableCheck[]) {
|
||||
super(`必交付物缺一不得提交放行:${missing.map((d) => d.label).join('、')}`);
|
||||
this.name = 'DeliverablesMissingError';
|
||||
}
|
||||
}
|
||||
|
||||
export type SubmitForReleaseInput = {
|
||||
readonly taskId: TaskId;
|
||||
readonly ownerId: StaffId;
|
||||
readonly humanCallCap: number;
|
||||
readonly deliverableId: string;
|
||||
readonly title: string;
|
||||
readonly basis: ReadonlyArray<{ k: string; v: string }>;
|
||||
readonly deliverables: readonly DeliverableCheck[];
|
||||
readonly criteria: readonly CriterionCheck[];
|
||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type SubmitOutcome = {
|
||||
readonly report: PrecheckReport;
|
||||
/** 预检全过才发卡;这里是发出的验收工单 ID */
|
||||
readonly workOrderId: string;
|
||||
};
|
||||
|
||||
export type LandInput = {
|
||||
readonly taskId: TaskId;
|
||||
readonly ownerId: StaffId;
|
||||
readonly humanCallCap: number;
|
||||
readonly selectedPorts: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
export type LandOutcome = {
|
||||
readonly verdict: LandingVerdict;
|
||||
/** 落地收敛后才发射的输出口 */
|
||||
readonly emittedPorts: readonly string[];
|
||||
readonly ledgerRowsMerged: number;
|
||||
/** 落地受阻时发出的落地失败卡 */
|
||||
readonly failureWorkOrderId?: string;
|
||||
};
|
||||
|
||||
export type ReleaseService = {
|
||||
/** 登记写类动作意向(入参冻结,存入草稿态) */
|
||||
registerIntent(taskId: TaskId, intent: Omit<ActionIntent, 'taskId' | 'state'>): Promise<ActionIntent>;
|
||||
listIntents(taskId: TaskId): Promise<ActionIntent[]>;
|
||||
|
||||
/** 预检 → 全过才发验收工单;不过即抛 `PrecheckFailedError`,打回环内修正 */
|
||||
submitForRelease(input: SubmitForReleaseInput): Promise<SubmitOutcome>;
|
||||
/** 只跑预检不发卡 —— 环内随时可看「还差什么」 */
|
||||
precheck(taskId: TaskId): Promise<PrecheckReport>;
|
||||
|
||||
/** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口 */
|
||||
land(input: LandInput): Promise<LandOutcome>;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* module: release —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
ActionIntent,
|
||||
CriterionCheck,
|
||||
DeliverableCheck,
|
||||
LandInput,
|
||||
LandOutcome,
|
||||
LandingOutcome,
|
||||
LandingVerdict,
|
||||
PrecheckFinding,
|
||||
PrecheckItem,
|
||||
PrecheckReport,
|
||||
ReleaseService,
|
||||
SubmitForReleaseInput,
|
||||
SubmitOutcome,
|
||||
} from './contract.ts';
|
||||
export { DeliverablesMissingError, PrecheckFailedError } from './contract.ts';
|
||||
|
||||
export type {
|
||||
CardGateway,
|
||||
Clock,
|
||||
IdGenerator,
|
||||
IntentStore,
|
||||
LedgerGateway,
|
||||
MemoryRecorder,
|
||||
PortEmitter,
|
||||
TaskContext,
|
||||
ToolGateway,
|
||||
} from './port.ts';
|
||||
|
||||
export type { ReleaseServiceDeps } from './internal/usecase/release-service.ts';
|
||||
export { createReleaseService } from './internal/usecase/release-service.ts';
|
||||
|
||||
export {
|
||||
idempotencyKey,
|
||||
landingVerdict,
|
||||
missingDeliverables,
|
||||
PRECHECK_ITEM_LABEL,
|
||||
PRECHECK_ITEMS,
|
||||
submittable,
|
||||
summarize,
|
||||
} from './internal/domain/precheck.ts';
|
||||
|
||||
export { createIntentStore } from './internal/adapter/intent-store.ts';
|
||||
export { actionIntentTable } from './internal/adapter/release-schema.ts';
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* release 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。
|
||||
*/
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { ActionIntent, TaskId } from '@contracts';
|
||||
import { actionIntentSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { IntentStore } from '../../port.ts';
|
||||
import { actionIntentTable } from './release-schema.ts';
|
||||
|
||||
type Row = typeof actionIntentTable.$inferSelect;
|
||||
|
||||
function toIntent(row: Row): ActionIntent {
|
||||
return actionIntentSchema.parse({
|
||||
id: row.id,
|
||||
taskId: row.taskId,
|
||||
tool: row.tool,
|
||||
input: row.input,
|
||||
idempotencyKey: row.idempotencyKey,
|
||||
state: row.state,
|
||||
label: row.label,
|
||||
...(row.detail === null ? {} : { detail: row.detail }),
|
||||
...(row.error === null ? {} : { error: row.error }),
|
||||
});
|
||||
}
|
||||
|
||||
export function createIntentStore(db: Db): IntentStore {
|
||||
return {
|
||||
async save(intent) {
|
||||
await db
|
||||
.insert(actionIntentTable)
|
||||
.values({
|
||||
id: intent.id,
|
||||
taskId: intent.taskId,
|
||||
tool: intent.tool,
|
||||
input: intent.input,
|
||||
idempotencyKey: intent.idempotencyKey,
|
||||
state: intent.state,
|
||||
label: intent.label,
|
||||
detail: intent.detail ?? null,
|
||||
error: intent.error ?? null,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
},
|
||||
async list(taskId: TaskId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(actionIntentTable)
|
||||
.where(eq(actionIntentTable.taskId, taskId))
|
||||
.orderBy(asc(actionIntentTable.createdAt));
|
||||
return rows.map(toIntent);
|
||||
},
|
||||
async setState(id, state, error) {
|
||||
await db
|
||||
.update(actionIntentTable)
|
||||
.set({ state, error: error ?? null })
|
||||
.where(eq(actionIntentTable.id, id));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* release 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||
* 动作意向持久于系统自持存储 —— 写即持久,断点可续(OS 09)。
|
||||
*/
|
||||
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const actionIntentTable = sys.table(
|
||||
'action_intent',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
tool: text('tool').notNull(),
|
||||
/** 入参冻结 —— 登记时即定,放行时按幂等键执行 */
|
||||
input: jsonb('input').notNull(),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
state: text('state').notNull().default('drafted'),
|
||||
label: text('label').notNull(),
|
||||
detail: text('detail'),
|
||||
error: text('error'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('action_intent_task_idx').on(t.taskId, t.state)],
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 落地预检 —— 纯判定,零 IO(编码规范 §5.3;语义出处 OS 7.3,AM 06)。
|
||||
*
|
||||
* 「**检查可落地,才允许放行**:发放行卡前,系统对全部动作意向执行预检 ——
|
||||
* ①入参 schema 校验 ②工具可用(未停用)与授权有效 ③连接可达
|
||||
* ④目标预校验(工具契约含 validate 能力时)⑤数据冲突检查。
|
||||
* 预检全过才发放行卡,卡面呈现的即已验证的动作清单;预检不过的意向打回环内修正,**不发卡**。」
|
||||
*/
|
||||
import type { AcceptanceLevel } from '@contracts';
|
||||
|
||||
export const PRECHECK_ITEMS = [
|
||||
'input-schema',
|
||||
'tool-available',
|
||||
'connection-reachable',
|
||||
'target-validate',
|
||||
'data-conflict',
|
||||
] as const;
|
||||
export type PrecheckItem = (typeof PRECHECK_ITEMS)[number];
|
||||
|
||||
export const PRECHECK_ITEM_LABEL: Readonly<Record<PrecheckItem, string>> = {
|
||||
'input-schema': '入参 schema 校验',
|
||||
'tool-available': '工具可用与授权有效',
|
||||
'connection-reachable': '连接可达',
|
||||
'target-validate': '目标预校验',
|
||||
'data-conflict': '数据冲突检查',
|
||||
};
|
||||
|
||||
export type PrecheckFinding = {
|
||||
readonly item: PrecheckItem;
|
||||
/** 关联的动作意向;数据冲突项无意向,留空 */
|
||||
readonly intentId?: string;
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
export type PrecheckReport = {
|
||||
/** 全过才可发卡 */
|
||||
readonly passed: boolean;
|
||||
readonly findings: readonly PrecheckFinding[];
|
||||
/** 已验证的动作清单 —— 通过时即验收工单卡面呈现的那份 */
|
||||
readonly verified: ReadonlyArray<{ id: string; label: string; effect: 'read' | 'write'; detail: string }>;
|
||||
};
|
||||
|
||||
export function summarize(
|
||||
findings: readonly PrecheckFinding[],
|
||||
verified: PrecheckReport['verified'],
|
||||
): PrecheckReport {
|
||||
return { passed: findings.length === 0, findings, verified };
|
||||
}
|
||||
|
||||
/* ── 必交付物与判据 ─────────────────────────────────────────────────── */
|
||||
|
||||
export type DeliverableCheck = { readonly key: string; readonly label: string; readonly present: boolean };
|
||||
export type CriterionCheck = { readonly key: string; readonly label: string; readonly level: AcceptanceLevel; readonly pass: boolean };
|
||||
|
||||
/** 必交付物**缺一不得提交放行**(AM 01) */
|
||||
export function missingDeliverables(checks: readonly DeliverableCheck[]): DeliverableCheck[] {
|
||||
return checks.filter((c) => !c.present);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判据全绿只是**可提交**,完成只出自放行通过(AM 10.2)。
|
||||
* L1/L2 由引擎预检;L3–L5 按分级执行 —— L5 是责任关,不参与「可提交」判定。
|
||||
*/
|
||||
export function submittable(checks: readonly CriterionCheck[]): boolean {
|
||||
return checks.filter((c) => c.level !== 'L5').every((c) => c.pass);
|
||||
}
|
||||
|
||||
/* ── 落地收敛 ───────────────────────────────────────────────────────── */
|
||||
|
||||
export type LandingOutcome = {
|
||||
readonly id: string;
|
||||
readonly ok: boolean;
|
||||
readonly error?: string;
|
||||
readonly retryable: boolean;
|
||||
};
|
||||
|
||||
export type LandingVerdict =
|
||||
/** 落地收敛 —— 输出口可发射,终态方成「放行完成」 */
|
||||
| { readonly kind: 'converged' }
|
||||
/** 落地受阻 —— **运行态**不是终态;走落地失败卡,处置后仍收敛于终态名录 */
|
||||
| { readonly kind: 'blocked'; readonly failures: readonly LandingOutcome[] };
|
||||
|
||||
/**
|
||||
* 「放行通过即刻执行(附幂等键);落地收敛,输出口才发射 —— 下游只见落地完成的任务。
|
||||
* 已成动作留存**不回滚**。」
|
||||
*/
|
||||
export function landingVerdict(outcomes: readonly LandingOutcome[]): LandingVerdict {
|
||||
const failures = outcomes.filter((o) => !o.ok);
|
||||
return failures.length === 0 ? { kind: 'converged' } : { kind: 'blocked', failures };
|
||||
}
|
||||
|
||||
/** 幂等键:任务 × 意向 —— 重放不重复触达世界(OS 7.3) */
|
||||
export function idempotencyKey(taskId: string, intentId: string): string {
|
||||
return `${taskId}:${intentId}`;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* release 用例 —— 放行门(编码规范 §5.3;语义出处 OS 7.3 / 09,AM 06)。
|
||||
*
|
||||
* 「预检放行,落地收敛」:检查可落地才允许放行 → 放行通过即刻落地(附幂等键)
|
||||
* → 落地收敛输出口才发射 → 终态方成「放行完成」。
|
||||
*/
|
||||
import type { ActionIntent, TaskId } from '@contracts';
|
||||
import { emissionPortNames } from '@contracts';
|
||||
import type {
|
||||
LandInput,
|
||||
LandOutcome,
|
||||
ReleaseService,
|
||||
SubmitForReleaseInput,
|
||||
SubmitOutcome,
|
||||
} from '../../contract.ts';
|
||||
import { DeliverablesMissingError, PrecheckFailedError } from '../../contract.ts';
|
||||
import type {
|
||||
CardGateway,
|
||||
Clock,
|
||||
IdGenerator,
|
||||
IntentStore,
|
||||
LedgerGateway,
|
||||
MemoryRecorder,
|
||||
PortEmitter,
|
||||
TaskContext,
|
||||
ToolGateway,
|
||||
} from '../../port.ts';
|
||||
import type { LandingOutcome, PrecheckFinding } from '../domain/precheck.ts';
|
||||
import { idempotencyKey, landingVerdict, missingDeliverables, summarize } from '../domain/precheck.ts';
|
||||
|
||||
export type ReleaseServiceDeps = {
|
||||
readonly intents: IntentStore;
|
||||
readonly ledger: LedgerGateway;
|
||||
readonly tools: ToolGateway;
|
||||
readonly cards: CardGateway;
|
||||
readonly tasks: TaskContext;
|
||||
readonly ports: PortEmitter;
|
||||
readonly memory: MemoryRecorder;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
async function runPrecheck(taskId: TaskId) {
|
||||
const activityId = await deps.tasks.activityIdOf(taskId);
|
||||
const intents = (await deps.intents.list(taskId)).filter((i) => i.state === 'drafted' || i.state === 'prechecked');
|
||||
const findings: PrecheckFinding[] = [];
|
||||
const verified: Array<{ id: string; label: string; effect: 'read' | 'write'; detail: string }> = [];
|
||||
|
||||
for (const intent of intents) {
|
||||
// ① 入参 schema 校验
|
||||
const schema = await deps.tools.validateInput(intent.tool, intent.input);
|
||||
if (!schema.ok) {
|
||||
findings.push({ item: 'input-schema', intentId: intent.id, message: `${intent.label}:${schema.errors.join('; ')}` });
|
||||
continue;
|
||||
}
|
||||
// ② 工具可用与授权有效 / ③ 连接可达
|
||||
const invocable = await deps.tools.isInvocable(intent.tool, activityId);
|
||||
if (!invocable.ok) {
|
||||
findings.push({
|
||||
item: 'tool-available',
|
||||
intentId: intent.id,
|
||||
message: `${intent.label}:${invocable.reason ?? '工具不可用'}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// ④ 目标预校验 —— 工具契约含 validate 能力时调之
|
||||
const target = await deps.tools.validateTarget({
|
||||
tool: intent.tool,
|
||||
activityId,
|
||||
taskId,
|
||||
payload: intent.input,
|
||||
});
|
||||
if (target !== undefined && !target.ok) {
|
||||
findings.push({
|
||||
item: 'target-validate',
|
||||
intentId: intent.id,
|
||||
message: `${intent.label}:${target.error ?? '目标预校验未过'}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
verified.push({ id: intent.id, label: intent.label, effect: 'write', detail: intent.detail ?? '' });
|
||||
}
|
||||
|
||||
// ⑤ 数据冲突检查:他单已改同一字段即预检不过,打回环内基于新正式版重拟
|
||||
const conflicts = await deps.ledger.checkConflicts(taskId);
|
||||
for (const conflict of conflicts) {
|
||||
findings.push({
|
||||
item: 'data-conflict',
|
||||
message: `字段 ${conflict.field} 已被他单改动,请基于新正式版重拟`,
|
||||
});
|
||||
}
|
||||
|
||||
return summarize(findings, verified);
|
||||
}
|
||||
|
||||
return {
|
||||
async registerIntent(taskId, draft) {
|
||||
const intent: ActionIntent = { ...draft, taskId, state: 'drafted' };
|
||||
await deps.intents.save(intent);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'action-intent',
|
||||
title: `登记动作意向:${intent.label}`,
|
||||
detail: { tool: intent.tool, idempotencyKey: intent.idempotencyKey },
|
||||
});
|
||||
return intent;
|
||||
},
|
||||
|
||||
async listIntents(taskId) {
|
||||
return deps.intents.list(taskId);
|
||||
},
|
||||
|
||||
async precheck(taskId) {
|
||||
return runPrecheck(taskId);
|
||||
},
|
||||
|
||||
/** 预检全过才发放行卡 —— 不过即抛,**不发卡**(OS 7.3) */
|
||||
async submitForRelease(input: SubmitForReleaseInput): Promise<SubmitOutcome> {
|
||||
const missing = missingDeliverables(input.deliverables);
|
||||
if (missing.length > 0) throw new DeliverablesMissingError(missing);
|
||||
|
||||
const report = await runPrecheck(input.taskId);
|
||||
if (!report.passed) throw new PrecheckFailedError(report);
|
||||
|
||||
for (const item of report.verified) await deps.intents.setState(item.id, 'prechecked');
|
||||
|
||||
const card = await deps.cards.issueAcceptance({
|
||||
taskId: input.taskId,
|
||||
ownerId: input.ownerId,
|
||||
humanCallCap: input.humanCallCap,
|
||||
title: input.title,
|
||||
basis: input.basis,
|
||||
deliverable: input.deliverableId,
|
||||
criteria: input.criteria.map((c) => ({ label: c.label, level: c.level, pass: c.pass })),
|
||||
// 卡面呈现的即**已验证**的动作清单
|
||||
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
||||
});
|
||||
|
||||
return { report, workOrderId: card.id };
|
||||
},
|
||||
|
||||
/** 放行通过即刻落地 —— 草稿合并 + 动作执行;收敛才发射输出口 */
|
||||
async land(input: LandInput): Promise<LandOutcome> {
|
||||
const activityId = await deps.tasks.activityIdOf(input.taskId);
|
||||
|
||||
// 放行写:草稿层合并入正式版(原子,过冲突检查),此刻才产生数据事件
|
||||
const merged = await deps.ledger.releaseWrite(input.taskId);
|
||||
await deps.memory.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'ledger-write',
|
||||
title: `放行写合并 ${merged.mergedRows} 行`,
|
||||
});
|
||||
|
||||
// 外部动作意向执行,附幂等键;已成动作留存不回滚
|
||||
const intents = (await deps.intents.list(input.taskId)).filter((i) => i.state === 'prechecked');
|
||||
const outcomes: LandingOutcome[] = [];
|
||||
for (const intent of intents) {
|
||||
const outcome = await deps.tools.execute({
|
||||
tool: intent.tool,
|
||||
activityId,
|
||||
taskId: input.taskId,
|
||||
payload: intent.input,
|
||||
idempotencyKey: idempotencyKey(input.taskId, intent.id),
|
||||
});
|
||||
outcomes.push(outcome);
|
||||
await deps.intents.setState(intent.id, outcome.ok ? 'landed' : 'failed', outcome.error);
|
||||
}
|
||||
|
||||
const verdict = landingVerdict(outcomes);
|
||||
|
||||
if (verdict.kind === 'blocked') {
|
||||
// 落地受阻是**运行态**不是终态:发落地失败卡,处置后仍收敛于终态名录
|
||||
const card = await deps.cards.issueLandingFailure({
|
||||
taskId: input.taskId,
|
||||
ownerId: input.ownerId,
|
||||
humanCallCap: input.humanCallCap,
|
||||
failures: verdict.failures.map((f) => ({
|
||||
label: intents.find((i) => i.id === f.id)?.label ?? f.id,
|
||||
reason: f.error ?? '未知错误',
|
||||
})),
|
||||
});
|
||||
await deps.memory.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'release-verdict',
|
||||
title: `落地受阻:${verdict.failures.length} 个动作失败`,
|
||||
});
|
||||
return {
|
||||
verdict,
|
||||
emittedPorts: [],
|
||||
ledgerRowsMerged: merged.mergedRows,
|
||||
failureWorkOrderId: card.id,
|
||||
};
|
||||
}
|
||||
|
||||
// 落地收敛,输出口才发射 —— 下游只见落地完成的任务
|
||||
const ports = await deps.tasks.outputPortsOf(input.taskId);
|
||||
const emitted = emissionPortNames(
|
||||
{
|
||||
mandatory: ports.mandatory.map((name) => ({ name, payload: {} })),
|
||||
selective: ports.selective.map((g) => ({
|
||||
group: g.group,
|
||||
ports: g.ports.map((name) => ({ name, payload: {} })),
|
||||
})),
|
||||
},
|
||||
input.selectedPorts,
|
||||
);
|
||||
for (const port of emitted) {
|
||||
await deps.ports.emit({ taskId: input.taskId, port, payload: {} });
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'release-verdict',
|
||||
title: `落地收敛,发射输出口:${emitted.join('、') || '(无)'}`,
|
||||
});
|
||||
|
||||
return { verdict, emittedPorts: emitted, ledgerRowsMerged: merged.mergedRows };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@modules/release",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./index.ts" }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* module: release —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { ActionIntent, StaffId, TaskId } from '@contracts';
|
||||
import type { LandingOutcome } from './internal/domain/precheck.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
export type IntentStore = {
|
||||
save(intent: ActionIntent): Promise<void>;
|
||||
list(taskId: TaskId): Promise<ActionIntent[]>;
|
||||
setState(id: string, state: ActionIntent['state'], error?: string): Promise<void>;
|
||||
};
|
||||
|
||||
/** 台账 —— app 注入 `ledger`(L1);放行写与冲突检查都在它那 */
|
||||
export type LedgerGateway = {
|
||||
checkConflicts(taskId: TaskId): Promise<Array<{ field: string; key: Record<string, string | number> }>>;
|
||||
releaseWrite(taskId: TaskId): Promise<{ mergedRows: number; emittedEvents: number }>;
|
||||
};
|
||||
|
||||
/** 工具 —— app 注入 `toolbox`(L2);预检的②③④项与落地执行都经它 */
|
||||
export type ToolGateway = {
|
||||
/** 工具可用(未停用)与授权有效 */
|
||||
isInvocable(tool: string, activityId: string): Promise<{ ok: boolean; reason?: string }>;
|
||||
/** 入参 schema 校验 */
|
||||
validateInput(tool: string, input: Record<string, unknown>): Promise<{ ok: boolean; errors: string[] }>;
|
||||
/** 目标预校验:工具契约含 validate 能力时调之(ADR-005) */
|
||||
validateTarget(input: {
|
||||
tool: string;
|
||||
activityId: string;
|
||||
taskId: TaskId;
|
||||
payload: Record<string, unknown>;
|
||||
}): Promise<{ ok: boolean; error?: string } | undefined>;
|
||||
/** 落地执行 —— 附幂等键 */
|
||||
execute(input: {
|
||||
tool: string;
|
||||
activityId: string;
|
||||
taskId: TaskId;
|
||||
payload: Record<string, unknown>;
|
||||
idempotencyKey: string;
|
||||
}): Promise<LandingOutcome>;
|
||||
};
|
||||
|
||||
/** 人类工具 —— app 注入 `human-tool`(L3):发验收工单与落地失败卡 */
|
||||
export type CardGateway = {
|
||||
issueAcceptance(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
title: string;
|
||||
basis: ReadonlyArray<{ k: string; v: string }>;
|
||||
deliverable: string;
|
||||
criteria: ReadonlyArray<{ label: string; level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5'; pass: boolean }>;
|
||||
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
||||
}): Promise<{ id: string }>;
|
||||
issueLandingFailure(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
failures: ReadonlyArray<{ label: string; reason: string }>;
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
/** 任务上下文 —— app 注入(活动 ID 与契约的输出口声明) */
|
||||
export type TaskContext = {
|
||||
activityIdOf(taskId: TaskId): Promise<string>;
|
||||
outputPortsOf(taskId: TaskId): Promise<{ mandatory: string[]; selective: Array<{ group: string; ports: string[] }> }>;
|
||||
};
|
||||
|
||||
/** 输出口发射 —— app 注入 `event`(L1):落地收敛**之后**才发 */
|
||||
export type PortEmitter = {
|
||||
emit(input: { taskId: TaskId; port: string; payload: Record<string, unknown> }): Promise<void>;
|
||||
};
|
||||
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'action-intent' | 'release-verdict' | 'ledger-write';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
Reference in New Issue
Block a user