Files
easyai-codex-plugins/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs
T

453 lines
13 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { mkdir, rmdir } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import {
attachmentManifest,
canonical,
contentDigest,
parseConfirmationToken,
readJson,
verifySecret,
writeJsonAtomic,
} from "./intake-lib.mjs";
const baseToken = "Ed21b9VwNaZfiesxWgDc67wXnoc";
const taskTableId = "tblelMMlOif1YHPD";
const formShareToken = "shrcnT6yRL9olcpFkppFkUcKe5e";
const formUrl =
"https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e";
const intakeHome = resolve(
process.env.EASYAI_INTAKE_HOME ?? homedir(),
".easyai-intake",
);
let lockPath;
let receiptPath;
let receipt;
let submissionStarted = false;
class Completed extends Error {
constructor(output) {
super("completed");
this.output = output;
}
}
try {
const confirmation = argument("--confirm");
const { draftId, secret } = parseConfirmationToken(confirmation);
const dryRun = process.argv.includes("--dry-run");
const retryUnknown = process.argv.includes("--retry-unknown");
const draftPath = resolve(intakeHome, "drafts", `${draftId}.json`);
const draft = await readJson(draftPath);
if (!draft || draft.schema_version !== 2 || draft.status_code !== "prepared")
throw new Error("确认草稿不存在或已过期,请重新预览");
if (!verifySecret(secret, draft.confirmation_token_hash))
throw new Error("确认令牌缺失或已过期,请重新预览并确认");
const auth = lark(["auth", "status", "--json", "--verify"]);
const user = auth.identities?.user;
if (!user?.verified || !user.openId)
throw new Error("当前电脑未完成飞书用户授权");
if (user.openId !== draft.owner_open_id)
throw new Error("当前飞书身份与预览负责人不一致,请重新预览");
const attachments = await attachmentManifest(draft.value.attachments);
const currentDigest = contentDigest(draft.value, user.openId, attachments);
if (currentDigest !== draft.content_digest)
throw new Error("内容或附件已改变,旧确认令牌失效,请重新预览");
lockPath = resolve(intakeHome, "locks", draftId);
if (!dryRun) await acquireLock(lockPath);
receiptPath = resolve(intakeHome, "receipts", `${draft.submission_key}.json`);
receipt = await readJson(receiptPath, {
schema_version: 2,
status_code: "prepared",
draft_id: draftId,
submission_key: draft.submission_key,
owner_open_id: user.openId,
prepared_at: draft.prepared_at,
});
if (receipt.status_code === "submitted") {
throw new Completed({ ok: true, duplicate: true, ...receipt });
}
const existing = findExistingSubmission(draft.submission_key);
if (existing) {
finalizeOwnerIntent(draft, existing);
receipt = submittedReceipt(draft, existing);
if (!dryRun) await writeJsonAtomic(receiptPath, receipt);
throw new Completed({ ok: true, duplicate: true, ...receipt });
}
if (["submitting", "unknown"].includes(receipt.status_code) && !retryUnknown)
throw new Error(
`提交 ${draft.submission_key} 的结果尚未确认;已查询 Base 未找到记录。请核对后使用 --retry-unknown 明确重试,不能盲目重提`,
);
const form = lark([
"base",
"+form-detail",
"--share-token",
formShareToken,
"--as",
"user",
]);
if (form.base_token !== baseToken)
throw new Error("飞书表单指向了非预期 Base");
const fields = new Map(
(form.questions ?? []).map((field) => [field.title, field]),
);
const value = draft.value;
const payload = {
类型: value.type,
标题: value.title,
需求背景与目标: value.background,
期望结果: value.expected_result,
优先级: value.priority,
产品负责人: [{ id: user.openId }],
提交幂等键: draft.submission_key,
提交来源: "Codex Skill",
...(value.acceptance_criteria.length
? {
验收标准: value.acceptance_criteria
.map((item, index) => `${index + 1}. ${item}`)
.join("\n"),
}
: {}),
...(value.environment ? { 发生环境: value.environment } : {}),
...(value.reproduction_steps?.length
? {
复现步骤: value.reproduction_steps
.map((item, index) => `${index + 1}. ${item}`)
.join("\n"),
}
: {}),
...(value.actual_result ? { 实际结果: value.actual_result } : {}),
...(value.impact_scope.length ? { 影响范围: value.impact_scope } : {}),
...(value.constraints.length || value.non_goals.length
? {
约束与非目标: [
...value.constraints.map((item) => `约束:${item}`),
...value.non_goals.map((item) => `非目标:${item}`),
].join("\n"),
}
: {}),
...(value.desired_at ? { 期望完成时间: value.desired_at } : {}),
...(value.references ? { 参考资料: value.references } : {}),
};
validateLiveForm(fields, payload, value);
const submission = {
fields: payload,
...(value.attachments.length
? { attachments: { 附件: value.attachments } }
: {}),
};
if (!dryRun) {
submissionStarted = true;
receipt = {
...receipt,
status_code: "submitting",
submission_url: formUrl,
content_digest: draft.content_digest,
submitting_at: new Date().toISOString(),
status: "正在提交;进程中断时先按幂等键核对 Base",
};
await writeJsonAtomic(receiptPath, receipt);
}
let submitted;
try {
submitted = lark([
"base",
"+form-submit",
"--share-token",
formShareToken,
...(value.attachments.length ? ["--base-token", baseToken] : []),
"--json",
JSON.stringify(submission),
"--as",
"user",
...(dryRun ? ["--dry-run"] : []),
]);
if (!dryRun) {
const created =
recordFromResult(submitted) ??
findExistingSubmission(draft.submission_key);
if (!created) throw new Error("表单返回成功但无法按提交幂等键定位新记录");
finalizeOwnerIntent(draft, created);
submitted = created;
}
} catch (error) {
if (!dryRun) {
receipt = {
...receipt,
status_code: "unknown",
unknown_at: new Date().toISOString(),
status: "提交调用未获得确定结果;重试前必须按提交幂等键查询 Base",
last_error: error instanceof Error ? error.message : "未知错误",
};
await writeJsonAtomic(receiptPath, receipt);
}
throw error;
}
if (dryRun) {
throw new Completed({
ok: true,
dry_run: true,
draft_id: draftId,
submission_key: draft.submission_key,
submitted,
});
}
receipt = submittedReceipt(draft, submitted);
await writeJsonAtomic(receiptPath, receipt);
console.log(
JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2),
);
} catch (error) {
if (error instanceof Completed) {
console.log(JSON.stringify(error.output, null, 2));
} else {
if (
submissionStarted &&
receiptPath &&
receipt?.status_code === "submitting"
) {
receipt = {
...receipt,
status_code: "unknown",
unknown_at: new Date().toISOString(),
status: "提交进程中断;重试前必须按提交幂等键查询 Base",
last_error: error instanceof Error ? error.message : "未知错误",
};
await writeJsonAtomic(receiptPath, receipt).catch(() => undefined);
}
console.error(
JSON.stringify({
ok: false,
status_code: receipt?.status_code,
submission_key: receipt?.submission_key,
error: error instanceof Error ? error.message : "未知错误",
}),
);
process.exitCode = 1;
}
} finally {
if (lockPath) await rmdir(lockPath).catch(() => undefined);
}
function submittedReceipt(draft, result) {
const record = recordFromResult(result) ?? result;
return {
schema_version: 2,
submission_url: formUrl,
draft_id: draft.draft_id,
submission_key: draft.submission_key,
owner_open_id: draft.owner_open_id,
content_digest: draft.content_digest,
status_code: "submitted",
status: "已登记,等待系统分配任务编号",
submitted_at: new Date().toISOString(),
...(record?.record_id ? { record_id: record.record_id } : {}),
};
}
function recordFromResult(result) {
return (
result?.record ??
result?.data?.[0] ??
(result?.record_id ? result : undefined)
);
}
function finalizeOwnerIntent(draft, record) {
const recordId = record?.record_id;
if (!recordId)
throw new Error("已登记记录缺少 record_id,无法固化原始意图指纹");
lark([
"base",
"+record-upsert",
"--base-token",
baseToken,
"--table-id",
taskTableId,
"--record-id",
recordId,
"--json",
JSON.stringify({ 原始意图指纹: draft.content_digest }),
"--as",
"user",
]);
}
function findExistingSubmission(submissionKey) {
const result = lark([
"base",
"+record-search",
"--base-token",
baseToken,
"--table-id",
taskTableId,
"--keyword",
submissionKey,
"--search-field",
"提交幂等键",
"--field-id",
"提交幂等键",
"--field-id",
"任务编号",
"--limit",
"2",
"--format",
"json",
"--as",
"user",
]);
const records = Array.isArray(result?.data) ? result.data : [];
return records.find((record) => {
const fields = record.fields ?? record;
return scalarText(fields["提交幂等键"]) === submissionKey;
});
}
function validateLiveForm(fields, payload, value) {
const expected = {
类型: "select",
标题: "text",
需求背景与目标: "text",
期望结果: "text",
优先级: "select",
产品负责人: "user",
提交幂等键: "text",
提交来源: "select",
};
for (const [name, type] of Object.entries(expected))
assertField(fields, name, type);
for (const name of Object.keys(payload))
if (!fields.has(name)) throw new Error(`受控表单缺少字段: ${name}`);
for (const field of fields.values())
if (
field.required &&
field.title !== "任务编号(系统自动生成,请勿填写)" &&
!hasPayloadValue(payload[field.title])
)
throw new Error(`受控表单仍要求非 V2 必填字段: ${field.title}`);
assertSelectOptions(fields, "类型", [value.type]);
assertSelectOptions(fields, "优先级", [value.priority]);
assertSelectOptions(fields, "提交来源", ["Codex Skill"]);
if (value.impact_scope.length)
assertSelectOptions(fields, "影响范围", value.impact_scope);
if (value.attachments.length) assertField(fields, "附件", "attachment");
}
function assertField(fields, name, type) {
const field = fields.get(name);
if (!field) throw new Error(`受控表单缺少必需字段: ${name}`);
if (field.type !== type)
throw new Error(
`受控表单字段 ${name} 类型错误: 期望 ${type},实际 ${field.type}`,
);
}
function hasPayloadValue(value) {
return (
value !== undefined &&
value !== null &&
value !== "" &&
(!Array.isArray(value) || value.length > 0)
);
}
function scalarText(value) {
if (typeof value === "string") return value;
if (Array.isArray(value))
return value
.map((part) => (typeof part === "string" ? part : (part?.text ?? "")))
.join("");
return value?.text ?? "";
}
async function acquireLock(path) {
try {
await mkdir(resolve(path, ".."), { recursive: true, mode: 0o700 });
await mkdir(path, { mode: 0o700 });
} catch (error) {
if (error?.code === "EEXIST")
throw new Error("同一草稿正在提交,请等待当前提交完成");
throw error;
}
}
function lark(args) {
try {
const output = execFileSync(
process.env.EASYAI_LARK_CLI ?? "lark-cli",
args,
{
encoding: "utf8",
env: {
...process.env,
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1",
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
return unwrapLark(output);
} catch (error) {
for (const output of [error?.stdout, error?.stderr]) {
const message = larkErrorMessage(output);
if (message) throw new Error(message);
}
throw error;
}
}
function argument(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function unwrapLark(output) {
const envelope = JSON.parse(output);
if (envelope.ok === false)
throw new Error(larkErrorMessage(output) ?? "飞书 CLI 调用失败");
return envelope.data ?? envelope;
}
function larkErrorMessage(output) {
if (typeof output !== "string" || !output.trim()) return undefined;
try {
const envelope = JSON.parse(output);
if (envelope.ok !== false) return undefined;
return (
[envelope.error?.message, envelope.error?.hint]
.filter(Boolean)
.join(": ") || undefined
);
} catch {
return undefined;
}
}
function assertSelectOptions(fields, name, values) {
const options = new Set(
(fields.get(name)?.options ?? []).map((option) => option.name),
);
for (const value of values)
if (!options.has(value))
throw new Error(`受控表单字段 ${name} 缺少选项: ${value}`);
}
// Used by focused tests to prove that attachment metadata participates in a draft.
export const __test = { canonical };