@zq-silk/yui 2.11.0 → 2.14.0

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 (89) hide show
  1. package/README.md +5 -0
  2. package/dist/artifacts/artifactCapability.js +14 -8
  3. package/dist/artifacts/artifactCommitLock.js +7 -5
  4. package/dist/artifacts/jobArtifact.js +93 -0
  5. package/dist/artifacts/jobArtifactTransport.js +13 -0
  6. package/dist/artifacts/taskArtifactRepository.js +58 -10
  7. package/dist/cli/commandCatalog.js +45 -14
  8. package/dist/cli/commandDiscovery.js +1 -1
  9. package/dist/commands/durableJobCommands.js +31 -6
  10. package/dist/commands/globalRoleCommands.js +5 -0
  11. package/dist/commands/scheduleCommands.js +14 -3
  12. package/dist/commands/taskCommands.js +5 -2
  13. package/dist/context/taskCatalog.js +6 -1
  14. package/dist/controlPlaneCli.js +7 -2
  15. package/dist/controller/agentJobControl.js +391 -0
  16. package/dist/controller/controller.js +24 -3
  17. package/dist/controller/fileSchedulerStoreAdapter.js +12 -1
  18. package/dist/controller/globalInputDelivery.js +8 -1
  19. package/dist/controller/jobArtifactControl.js +111 -0
  20. package/dist/controller/jobControl.js +4 -0
  21. package/dist/controller/jobDefinitionControl.js +191 -19
  22. package/dist/controller/jobSupervisor.js +83 -3
  23. package/dist/controller/jobTaskDelegationControl.js +193 -0
  24. package/dist/controller/runtime.js +9 -1
  25. package/dist/controller/scheduleControl.js +83 -20
  26. package/dist/integration/gitIntegrationService.js +2 -0
  27. package/dist/job/agentJob.js +131 -0
  28. package/dist/job/agentJobObservation.js +61 -0
  29. package/dist/job/agentRoleScope.js +33 -0
  30. package/dist/job/durableJob.js +63 -28
  31. package/dist/job/jobAssignmentScope.js +1 -0
  32. package/dist/job/jobBusinessResult.js +72 -0
  33. package/dist/job/jobCleanupInspection.js +4 -0
  34. package/dist/job/jobDefinition.js +26 -2
  35. package/dist/job/jobRunner.js +4 -3
  36. package/dist/job/projectRefreshRunner.js +54 -18
  37. package/dist/job/projectSync.js +162 -0
  38. package/dist/job/scheduleService.js +55 -0
  39. package/dist/job/taskDelegation.js +48 -0
  40. package/dist/message/messageContinuation.js +1 -1
  41. package/dist/resources/projectResourceService.js +1 -1
  42. package/dist/resources/resourceGc.js +1 -1
  43. package/dist/role/role.js +12 -0
  44. package/dist/schedule/schedule.js +65 -15
  45. package/dist/scheduler/wakeReason.js +4 -0
  46. package/dist/storage/agentJobSchema.js +10 -0
  47. package/dist/storage/recordValidation.js +2 -0
  48. package/dist/storage/sqliteSchema.js +77 -6
  49. package/dist/storage/sqliteStore.js +53 -0
  50. package/dist/storage/storageVersions.js +1 -1
  51. package/dist/storage/storeRpc.js +3 -0
  52. package/dist/storage/taskCatalog.js +10 -2
  53. package/dist/storage/upgrade/jobBusinessResultMigration.js +20 -0
  54. package/dist/storage/upgrade/scheduleDispatchMigration.js +15 -0
  55. package/dist/task/archivePreflight.js +15 -0
  56. package/dist/task/completionReadiness.js +7 -0
  57. package/dist/task/task.js +17 -1
  58. package/dist/verification/verificationGateService.js +4 -0
  59. package/dist/web/assets/client/app/catalog.js +16 -0
  60. package/dist/web/assets/client/app/settings.js +4 -0
  61. package/dist/web/assets/client/i18n/en.js +75 -2
  62. package/dist/web/assets/client/i18n/zh-CN.js +75 -2
  63. package/dist/web/assets/client/index.js +2 -0
  64. package/dist/web/assets/client/layout/manageShell.js +9 -0
  65. package/dist/web/assets/client/layout/router.js +1 -0
  66. package/dist/web/assets/client/views/jobs.js +166 -37
  67. package/dist/web/assets/client/views/permissions.js +129 -0
  68. package/dist/web/assets/client/views/task/files.js +5 -4
  69. package/dist/web/assets/client/views/task/page.js +5 -1
  70. package/dist/web/assets/shared/manageNav.js +1 -0
  71. package/dist/web/assets/shell/settingsContent.js +4 -0
  72. package/dist/web/assets/styles/views/settings.js +13 -0
  73. package/dist/web/taskPreviews.js +2 -2
  74. package/dist/web/webJobs.js +36 -11
  75. package/dist/web/webMaterials.js +21 -9
  76. package/dist/web/webPermissions.js +97 -0
  77. package/dist/web/webServer.js +44 -5
  78. package/dist/web/webTaskSurface.js +1 -0
  79. package/docs/authorization.md +35 -0
  80. package/docs/job-runs.md +392 -17
  81. package/docs/release-workflow.md +77 -0
  82. package/docs/release-workflow.zh-CN.md +57 -0
  83. package/docs/schedules.md +69 -15
  84. package/docs/storage-baseline.md +38 -1
  85. package/docs/storage-baseline.zh-CN.md +27 -1
  86. package/docs/testing/verification-levels.md +2 -2
  87. package/docs/testing/verification-levels.zh-CN.md +2 -2
  88. package/i18n/README.zh-CN.md +4 -0
  89. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { validateAgentJobRun, assertAgentJobRunTransition } from "../job/agentJob.js";
1
2
  /**
2
3
  * SQLite WAL Store (task-21, work-item-3).
3
4
  *
@@ -1079,6 +1080,10 @@ export class SqliteTaskStore {
1079
1080
  throw new StorageRecordError("Task id is required.");
1080
1081
  }
1081
1082
  this.#mutate(() => {
1083
+ const previous = this.getTask(task.id);
1084
+ if (previous !== null && !isDeepStrictEqual(previous.source, task.source)) {
1085
+ throw new StorageRecordError("Task source is immutable after creation.");
1086
+ }
1082
1087
  const schedules = this.listSchedules({ kind: "task", taskId: task.id });
1083
1088
  if (task.status === "completed" && schedules.some(s => s.rule.kind === "once"
1084
1089
  && (s.state === "enabled" || s.state === "needs-attention"))) {
@@ -1117,6 +1122,18 @@ export class SqliteTaskStore {
1117
1122
  const tasks = this.#listPayload("task_records", "1=1", []);
1118
1123
  return this.#sortById(tasks, (task) => task.id);
1119
1124
  }
1125
+ listTaskDelegations(taskId) {
1126
+ const rows = this.#db.prepare(`SELECT task_id AS scope, job_id AS id, payload FROM durable_jobs
1127
+ WHERE json_extract(payload,'$.delegation.taskId') = ?
1128
+ UNION ALL SELECT 'global' AS scope, run_id AS id, payload FROM global_job_runs
1129
+ WHERE json_extract(payload,'$.delegation.taskId') = ? LIMIT 101`).all(taskId, taskId);
1130
+ return { complete: rows.length <= 100, items: rows.slice(0, 100).map(row => {
1131
+ const run = JSON.parse(row.payload);
1132
+ return { sourceRun: { scope: row.scope === "global" ? { kind: "global" }
1133
+ : { kind: "task", taskId: row.scope }, runId: row.id },
1134
+ jobId: run.definitionId, delegation: run.delegation, status: run.status };
1135
+ }) };
1136
+ }
1120
1137
  queryTaskCatalog(query) {
1121
1138
  return queryTaskCatalog(this.#db, query);
1122
1139
  }
@@ -1175,6 +1192,7 @@ export class SqliteTaskStore {
1175
1192
  return {
1176
1193
  ...base,
1177
1194
  pendingUserMessages: pendingCompletionMessages(this, taskId),
1195
+ agentJobs: this.listAgentJobRuns({ kind: "task", taskId }),
1178
1196
  managedWorkspaces: this.#sortById(this.#listPayload("managed_workspaces", "task_id = ?", [taskId]), (workspace) => managedWorkspaceKey(workspace.owner)),
1179
1197
  durableJobs: this.#sortById(this.#listPayload("durable_jobs", "task_id = ?", [taskId]), (job) => job.id),
1180
1198
  };
@@ -1332,6 +1350,41 @@ export class SqliteTaskStore {
1332
1350
  .run(run.id, this.#recordJson("global_job_runs", run), run.status);
1333
1351
  });
1334
1352
  }
1353
+ saveAgentJobRun(run) {
1354
+ validateAgentJobRun(run);
1355
+ if (run.scope.kind === "task")
1356
+ this.#requireTask(run.scope.taskId);
1357
+ this.#mutate(() => {
1358
+ const previous = this.getAgentJobRun(run.id);
1359
+ if (previous)
1360
+ assertAgentJobRunTransition(previous, run);
1361
+ this.#db.prepare(`INSERT INTO agent_job_runs VALUES(?,?,?,?)
1362
+ ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload`)
1363
+ .run(run.id, jobScopeKey(run.scope), run.definitionId, this.#recordJson("agent_job_runs", run));
1364
+ if (run.scope.kind === "task" && (!previous || previous.status !== run.status
1365
+ || !isDeepStrictEqual(previous.businessResult, run.businessResult) || previous.cancelRequestedAt !== run.cancelRequestedAt)) {
1366
+ const taskId = run.scope.taskId;
1367
+ this.saveEvent(taskId, createTaskEvent(this.nextEventId(taskId), taskId, "job.state-changed", {
1368
+ jobId: run.id, status: run.status,
1369
+ ...(previous ? { previousStatus: previous.status } : {}),
1370
+ ...(run.cancelRequestedAt ? { cancelRequestedAt: run.cancelRequestedAt } : {})
1371
+ }, new Date(run.updatedAt)));
1372
+ }
1373
+ });
1374
+ }
1375
+ getAgentJobRun(id) {
1376
+ return this.#getPayload("agent_job_runs", "run_id=?", [id]);
1377
+ }
1378
+ listAgentJobRuns(scope, definitionId) {
1379
+ return this.#listPayload("agent_job_runs", definitionId === undefined ? "scope_key=?" : "scope_key=? AND definition_id=?", definitionId === undefined ? [jobScopeKey(scope)] : [jobScopeKey(scope), definitionId]);
1380
+ }
1381
+ listAgentJobRunsForRole(recipient) {
1382
+ return this.#listPayload("agent_job_runs", `json_extract(payload,'$.attempts[#-1].recipient.scope')=?
1383
+ AND json_extract(payload,'$.attempts[#-1].recipient.roleName')=?
1384
+ AND COALESCE(json_extract(payload,'$.attempts[#-1].recipient.taskId'),'')=?
1385
+ AND (json_extract(payload,'$.attempts[#-1].terminal') IS NULL
1386
+ OR json_extract(payload,'$.businessResult') IS NULL)`, [recipient.scope, recipient.roleName, recipient.scope === "task" ? recipient.taskId : ""]);
1387
+ }
1335
1388
  getGlobalJobRun(id) {
1336
1389
  return this.#getPayload("global_job_runs", "run_id=?", [id]);
1337
1390
  }
@@ -1,4 +1,4 @@
1
- export const CURRENT_STORAGE_VERSION = "1.14";
1
+ export const CURRENT_STORAGE_VERSION = "1.20";
2
2
  export const MIN_SUPPORTED_STORAGE_VERSION = "1.0";
3
3
  export const STORAGE_FORMAT = "yui-home";
4
4
  export function isStorageVersion(value) {
@@ -73,6 +73,9 @@ const READ_ONLY_STORE_METHODS = new Set([
73
73
  "getJobDefinition",
74
74
  "listJobDefinitions",
75
75
  "getGlobalJobRun",
76
+ "getAgentJobRun",
77
+ "listAgentJobRuns",
78
+ "listAgentJobRunsForRole",
76
79
  "listGlobalJobRuns",
77
80
  "listActiveGlobalJobRuns",
78
81
  "listRoles",
@@ -21,6 +21,10 @@ export function queryTaskCatalog(db, query) {
21
21
  match.push("status = @status");
22
22
  params.status = query.status;
23
23
  }
24
+ if (query.source !== undefined) {
25
+ match.push(`EXISTS (SELECT 1 FROM task_records r WHERE r.task_id = facts.id AND
26
+ json_extract(r.payload, '$.source.kind') ${query.source === "job" ? "= 'job-run'" : "IS NULL"})`);
27
+ }
24
28
  if (query.project !== undefined) {
25
29
  match.push(`EXISTS (SELECT 1 FROM task_records r, json_each(r.payload, '$.projectBindings') p
26
30
  WHERE r.task_id = facts.id AND json_extract(p.value, '$.projectId') = @project)`);
@@ -57,6 +61,10 @@ export function queryTaskCatalog(db, query) {
57
61
  after.push("(createdAt, id) > (@afterDate, @afterId)");
58
62
  }
59
63
  const count = (table, condition) => `(SELECT count(*) FROM ${table} x WHERE x.task_id = c.task_id AND (${condition}))`;
64
+ const incoming = (statuses) => `(SELECT count(*) FROM global_job_runs x
65
+ WHERE json_extract(x.payload,'$.delegation.taskId') = c.task_id AND x.status IN (${statuses}))
66
+ + (SELECT count(*) FROM durable_jobs x WHERE x.task_id <> c.task_id
67
+ AND json_extract(x.payload,'$.delegation.taskId') = c.task_id AND x.status IN (${statuses}))`;
60
68
  const displaySummary = `CASE
61
69
  WHEN page.status IN ('completed','archived')
62
70
  AND json_extract(r.payload,'$.completionSummary') IS NOT NULL
@@ -76,8 +84,8 @@ export function queryTaskCatalog(db, query) {
76
84
  ${count("work_items", "1=1")} AS workItems,
77
85
  ${count("turns", "status = 'active'")} AS activeRuns,
78
86
  ${count("input_requests", "status = 'open'")} AS openInputs,
79
- ${count("durable_jobs", "status IN ('queued','running')")} AS pendingOperations,
80
- ${count("durable_jobs", "status = 'unknown-needs-attention'")} AS unknownOperations,
87
+ ${count("durable_jobs", "status IN ('queued','running')")} + ${incoming("'queued','running'")} AS pendingOperations,
88
+ ${count("durable_jobs", "status = 'unknown-needs-attention'")} + ${incoming("'unknown-needs-attention'")} AS unknownOperations,
81
89
  CASE WHEN c.status IN ('active','draft') THEN
82
90
  ${count("turns", "status = 'active'")} +
83
91
  ${count("work_items", "status = 'open' OR (status = 'accepted' AND json_extract(payload, '$.currentExecutionGroupId') IS NOT NULL)")} +
@@ -0,0 +1,20 @@
1
+ import { processBusinessResult } from "../../job/jobBusinessResult.js";
2
+ import { validateDurableJob } from "../../job/durableJob.js";
3
+ /** Preserve raw history and derive only what each original result actually proves. */
4
+ export function migrateJobBusinessResults(db) {
5
+ for (const table of ["durable_jobs", "global_job_runs"]) {
6
+ const rows = db.prepare(`SELECT rowid, payload FROM ${table}`).all();
7
+ for (const row of rows) {
8
+ const run = JSON.parse(row.payload);
9
+ if (run.businessResult !== undefined)
10
+ throw new Error("Unexpected business result in storage 1.14.");
11
+ validateDurableJob(run);
12
+ if (!run.result)
13
+ continue;
14
+ const migrated = validateDurableJob({ ...run, businessResult: processBusinessResult(run, run.result) });
15
+ db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
16
+ .run(15, table, `${"taskId" in run ? run.taskId : "global"}/${run.id}`, row.payload);
17
+ db.prepare(`UPDATE ${table} SET payload=? WHERE rowid=?`).run(JSON.stringify(migrated), row.rowid);
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,15 @@
1
+ import { validateSchedule } from "../../schedule/schedule.js";
2
+ /** Keep valid historical Session authority; never infer a service grant. */
3
+ export function migrateScheduleDispatch(db) {
4
+ const rows = db.prepare("SELECT rowid,scope_key,schedule_id,payload FROM schedules").all();
5
+ for (const row of rows) {
6
+ const old = JSON.parse(row.payload);
7
+ if (old.target !== undefined || old.authorization?.kind !== undefined)
8
+ throw new Error("Unexpected Schedule shape in storage 1.15.");
9
+ const next = validateSchedule({ ...old, target: { kind: "existing-job" },
10
+ authorization: { ...old.authorization, kind: "session" } });
11
+ db.prepare("INSERT INTO storage_migration_archive(migration_version,family,record_key,payload) VALUES(?,?,?,?)")
12
+ .run(16, "schedules", `${row.scope_key}/${row.schedule_id}`, row.payload);
13
+ db.prepare("UPDATE schedules SET payload=? WHERE rowid=?").run(JSON.stringify(next), row.rowid);
14
+ }
15
+ }
@@ -23,6 +23,9 @@ export function archiveSettlementChecks(store, task) {
23
23
  for (const job of store.listDurableJobs(task.id).filter(j => ["queued", "running"].includes(j.status) || (j.status === "unknown-needs-attention" && j.acknowledgedAt === undefined))) {
24
24
  add(`job:${task.id}/${job.id}`, "active-durable-job", "Task has an active or unacknowledged DurableJob.", "settled job", job.status, `yui job get --task ${task.id} --job ${job.id}`);
25
25
  }
26
+ for (const job of store.listAgentJobRuns({ kind: "task", taskId: task.id }).filter(run => !run.businessResult)) {
27
+ add(`job:${task.id}/${job.id}`, "active-durable-job", "Agent JobRun has no explicit business result.", "settled job", job.status, `yui job run-context --task ${task.id} --job ${job.definitionId} --run ${job.id}`);
28
+ }
26
29
  return checks;
27
30
  }
28
31
  /** Unknown execution is never permission to remove workspaces, even after
@@ -40,6 +43,18 @@ export function archiveExecutionChecks(store, taskId, home, phase = "before-remo
40
43
  if (blocker !== undefined)
41
44
  add(`job:${taskId}/${job.id}`, "unresolved-execution", blocker, job.status, `yui job get --task ${taskId} --job ${job.id}`);
42
45
  }
46
+ for (const job of store.listAgentJobRuns({ kind: "task", taskId })) {
47
+ const last = job.attempts.at(-1);
48
+ if (!last.terminal)
49
+ add(`job:${taskId}/${job.id}`, "unresolved-execution", "Agent Job input has no exact native terminal; business reporting alone is not quiescence.", job.status, `yui job run-evidence --task ${taskId} --job ${job.definitionId} --run ${job.id}`);
50
+ }
51
+ for (const role of store.listGlobalRoles().filter(role => role.jobOwner?.scope.kind === "task" && role.jobOwner.scope.taskId === taskId)) {
52
+ const binding = store.getGlobalRoleSessionSet(role.name)?.providerBinding;
53
+ if (binding?.run && !["completed", "failed", "cancelled", "rejected"].includes(binding.run.status)
54
+ || hasRuntimeLifecycleWork(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName: role.name })))) {
55
+ add(`global-role:${role.name}`, "unresolved-provider-input", "Task-owned Job Role retains an unresolved input or lifecycle operation.", binding?.run?.status ?? "lifecycle pending", `yui job session-inspect --task ${taskId} --job ${role.jobOwner.jobId}`);
56
+ }
57
+ }
43
58
  for (const role of store.listRoles(taskId)) {
44
59
  const resource = `role:${taskId}/${role.name}`;
45
60
  const action = `yui task role session inspect ${taskId} ${role.name}`;
@@ -81,6 +81,13 @@ export function projectCompletionReadiness(facts) {
81
81
  });
82
82
  }
83
83
  // Active DurableJobs (integration checks, etc.) must settle.
84
+ for (const job of facts.agentJobs ?? []) {
85
+ if (job.businessResult)
86
+ continue;
87
+ blockers.push({ code: "active-durable-job", ref: ref("durable-job", job.id),
88
+ reason: `Agent JobRun ${job.id} has no explicit business result.`,
89
+ fix: `inspect yui job run-context --task ${facts.task.id} --job ${job.definitionId} --run ${job.id}; settle its original input without replay` });
90
+ }
84
91
  for (const job of facts.durableJobs) {
85
92
  if (!ACTIVE_JOB_STATUSES.has(job.status))
86
93
  continue;
package/dist/task/task.js CHANGED
@@ -400,7 +400,7 @@ export function validateTask(task) {
400
400
  if (task.schemaVersion !== 1)
401
401
  throw new Error("Task must use schemaVersion 1.");
402
402
  const fields = [
403
- "schemaVersion", "id", "title", "type", "description", "priority", "tags", "dueAt",
403
+ "schemaVersion", "source", "id", "title", "type", "description", "priority", "tags", "dueAt",
404
404
  "projectBindings", "cwd", "workspaceIdentity", "status", "executionGate", "activationRequest",
405
405
  "settledActivationRequests", "completedAt", "completedBy", "completionSummary", "completionArtifactRefs",
406
406
  "retiredAt", "retiredBy", "retirementSummary", "retirementIsolation", "replacementTaskId",
@@ -411,6 +411,22 @@ export function validateTask(task) {
411
411
  throw new Error(`Task contains an unsupported field: ${field}.`);
412
412
  }
413
413
  requireSafeIdentity(task.id, "Task id");
414
+ if (task.source !== undefined) {
415
+ const source = task.source;
416
+ if (source.kind !== "job-run" || Object.keys(source).some(key => !["kind", "jobId", "run"].includes(key))) {
417
+ throw new Error("Invalid Task source.");
418
+ }
419
+ requireSafeIdentity(source.jobId, "Source Job id");
420
+ requireSafeIdentity(source.run.runId, "Source Run id");
421
+ if (Object.keys(source.run).some(key => !["scope", "runId"].includes(key))
422
+ || Object.keys(source.run.scope).some(key => !["kind", "taskId"].includes(key))
423
+ || !["global", "task"].includes(source.run.scope.kind))
424
+ throw new Error("Invalid Task source Run.");
425
+ if (source.run.scope.kind === "task")
426
+ requireSafeIdentity(source.run.scope.taskId, "Source Task id");
427
+ else if ("taskId" in source.run.scope)
428
+ throw new Error("Global source cannot name a Task.");
429
+ }
414
430
  requireText(task.title, "Task title");
415
431
  if (task.type !== undefined)
416
432
  requireSafeIdentity(task.type, "Task type");
@@ -62,6 +62,8 @@ export function beginGateVerification(store, identity, plan, now) {
62
62
  * artifact is self-contained evidence after the job's own logs are cleaned.
63
63
  */
64
64
  export async function recordGateArtifactFromJob(store, home, identity, plan, job, now) {
65
+ if (job.delegation)
66
+ throw new Error("Task delegation is not process verification evidence.");
65
67
  const steps = (job.result?.steps ?? []).map((result) => {
66
68
  const planned = job.steps.find((step) => step.name === result.name);
67
69
  return {
@@ -216,6 +218,8 @@ function sanitizeLogName(name) {
216
218
  * failure; the Integration gate has not started when bootstrap fails.
217
219
  */
218
220
  export function checkResultsFromGateJob(job, _home) {
221
+ if (job.delegation)
222
+ throw new Error("Task delegation is not process verification evidence.");
219
223
  const results = new Map((job.result?.steps ?? []).map((step) => [step.name, step]));
220
224
  const checks = [];
221
225
  for (const step of job.steps) {
@@ -98,6 +98,18 @@ function renderSidebar(deps, progress) {
98
98
  reset(deps, progress);
99
99
  });
100
100
  el.catalogCount.textContent = fill(t("catalog.count"), { shown: state.tasks.length, total: state.catalogTotal });
101
+ let source = el.filters.querySelector("select");
102
+ if (!source) {
103
+ source = h("select", { "aria-label": t("jobs.taskCategory") });
104
+ source.addEventListener("change", function () {
105
+ state.source = source.value; state.attentionFilter = null; state.query = ""; el.search.value = "";
106
+ syncUrl(state, false); reset(deps, progress);
107
+ });
108
+ el.filters.append(source);
109
+ }
110
+ source.replaceChildren(...["ordinary", "job", "all"].map(value =>
111
+ h("option", { value }, t("jobs.category." + value))));
112
+ source.value = state.query.trim() || state.attentionFilter ? "all" : state.source || "ordinary";
101
113
  el.catalogReset.hidden = !state.catalogCursor;
102
114
  el.catalogNext.hidden = !state.nextCursor && !state.catalogCursor;
103
115
  el.catalogAttentionReset.hidden = !state.attentionFilter;
@@ -137,6 +149,9 @@ function catalogQuery(state) {
137
149
  if (state.filter !== "all") query.set("status", state.filter);
138
150
  if (state.query.trim()) query.set("search", state.query.trim());
139
151
  if (state.attentionFilter) query.set("attention", state.attentionFilter);
152
+ // Search and attention remain cross-category discovery; the server owns filtering.
153
+ if (!state.query.trim() && !state.attentionFilter && state.source !== "all")
154
+ query.set("source", state.source || "ordinary");
140
155
  if (state.catalogCursor) query.set("cursor", state.catalogCursor);
141
156
  return query.toString();
142
157
  }
@@ -192,6 +207,7 @@ function applyFilters(deps, params) {
192
207
  state.query = params.get("q") || "";
193
208
  state.attentionFilter = ATTENTION_KINDS.includes(params.get("attention")) ? params.get("attention") : null;
194
209
  state.catalogAll = params.get("all") === "true";
210
+ state.source = ["ordinary", "job", "all"].includes(params.get("source")) ? params.get("source") : "ordinary";
195
211
  deps.el.search.value = state.query;
196
212
  }
197
213
 
@@ -9,6 +9,7 @@ import { clearPreference, readSessionAccessMode, writeSessionAccessMode } from "
9
9
  import { i18n, createManageShell, pageSections, registerManagePage, navigateManage } from "/assets/js/layout/manageShell.js";
10
10
  import { mountSkills } from "/assets/js/views/skills.js";
11
11
  import { mountJobs } from "/assets/js/views/jobs.js";
12
+ import { mountPermissions } from "/assets/js/views/permissions.js";
12
13
 
13
14
  const $ = selector => document.querySelector(selector);
14
15
  const t = i18n.t;
@@ -20,12 +21,14 @@ function readSection() {
20
21
  }
21
22
  let section = readSection();
22
23
  let jobs;
24
+ let permissions;
23
25
  const shell = createManageShell({ page: "settings", t, onNavigate: navigate });
24
26
  function showSection(next) {
25
27
  section = next;
26
28
  document.querySelectorAll("#settings-root > .manage-section[data-section]").forEach(panel => { panel.hidden = panel.dataset.section !== next; });
27
29
  shell.set(next);
28
30
  if (next === "jobs" && !jobs) jobs = mountJobs($("#global-jobs"), t);
31
+ if (next === "permissions" && !permissions) permissions = mountPermissions($("#permission-browser"), t);
29
32
  }
30
33
  function navigate(next) {
31
34
  navigateManage({ page: "settings", section: next });
@@ -39,6 +42,7 @@ registerManagePage("settings", { show: next => showSection(next.section) });
39
42
  i18n.subscribe(() => shell.render());
40
43
  i18n.subscribe(() => {
41
44
  if (jobs && !jobs.isDirty() && !jobs.isBusy()) jobs = mountJobs($("#global-jobs"), t);
45
+ permissions?.translate();
42
46
  });
43
47
  showSection(section);
44
48
  const skills = mountSkills($("#skill-browser"), t, { heading: false, onDirty: value => markDirty("skills", value) });
@@ -3,10 +3,83 @@
3
3
  // domain vocabulary (statuses, owners, actions) keeps the exact product terms.
4
4
  // Placeholders such as {count} are filled by fill() in lib/format.
5
5
  export const EN = {
6
+ "settings.section.permissions": "Command permissions",
7
+ "permissions.intro": "Read-only discovery templates, not authorization for a specific call. No rules or Grants are changed.",
8
+ "permissions.notice": "Compare CLI discovery by role. Visible means included in that role's default help/completion, not permission to execute. Choosing a role does not switch your identity.",
9
+ "permissions.commands": "Command × role matrix",
10
+ "permissions.capabilities": "Built-in capability contracts",
11
+ "permissions.view": "View",
12
+ "permissions.search": "Find a command or capability",
13
+ "permissions.role": "Role template",
14
+ "permissions.category": "Command category",
15
+ "permissions.allRoles": "Compare all role templates",
16
+ "permissions.allCategories": "All categories",
17
+ "permissions.configured": "Configured GlobalRole",
18
+ "permissions.role.public": "Terminal / unmanaged",
19
+ "permissions.role.operator": "Operator",
20
+ "permissions.role.global": "GlobalRole",
21
+ "permissions.role.leader": "Task Leader",
22
+ "permissions.role.worker": "Task Worker",
23
+ "permissions.role.reviewer": "Task Reviewer",
24
+ "permissions.role.unbound": "Unbound identity",
25
+ "permissions.command": "Command",
26
+ "permissions.visible": "Visible",
27
+ "permissions.hidden": "Hidden",
28
+ "permissions.tableHint": "Discovery only · select a command for role-specific usage and boundaries. Scroll horizontally to compare roles.",
29
+ "permissions.reason.visible": "Included by the current catalog projection. Options below are filtered by the same role audience as CLI help.",
30
+ "permissions.reason.hidden": "Not discoverable as an executable command in this audience's catalog. This is not a missing-Grant assessment.",
31
+ "permissions.unevaluated": "No specific authorization assessment performed. No target, live Session or Grant has been inspected.",
32
+ "permissions.boundaries": "Execution still checks the actual Session, Task/lifecycle, planning or delivery authority, Assignment/Review, Project/workspace and resource/Grant bounds as applicable. The catalog cannot enumerate every domain check; never infer authorization from visibility, command name or a query effect.",
33
+ "permissions.boundary.public": "Ordinary terminals and unmanaged sessions share the public surface. This does not grant a managed identity or authorize unrequested effects.",
34
+ "permissions.boundary.operator": "Requires the current Operator binding and the domain's operation-specific checks. An offline template cannot establish that binding.",
35
+ "permissions.boundary.global": "Configured GlobalRoles share this discovery audience, not Operator authority. Role configuration and exact domain scope remain authoritative.",
36
+ "permissions.boundary.leader": "Own Task and captured Project scope. Planning Sessions do not acquire delivery authority just because a Task becomes active.",
37
+ "permissions.boundary.assignment": "Worker and Reviewer share discovery templates, but their exact Assignment/Review, frozen references and writable workspace may differ.",
38
+ "permissions.boundary.unbound": "Incomplete/conflicting managed identity has no public fallback. Historical or revoked Sessions are rechecked at execution even if offline help is visible.",
39
+ "permissions.capabilityNotice": "Built-in descriptors from the existing registry definition, not a second CLI-to-capability map. Permission categories and required inputs are contracts, not role grants. Task-local plugin descriptors and live assessments are intentionally excluded.",
40
+ "permissions.effect": "Declared effect",
41
+ "permissions.requiredPermissions": "Required permission categories",
42
+ "permissions.requiredInputs": "Required target inputs",
43
+ "permissions.inputs": "All input fields",
44
+ "permissions.legend": "Meaning, limits and sources",
45
+ "permissions.states": "Exact capability discovery may return hidden, requestable (with a bounded request route), or authorized (rechecked at execution). Without exact input there is no access assessment; this page never labels a role/command pair authorized or requestable.",
46
+ "permissions.sources": "Sources",
47
+ "permissions.previous": "Previous",
48
+ "permissions.next": "Next",
49
+ "permissions.loading": "Loading templates…",
50
+ "permissions.empty": "No matching templates.",
51
+ "permissions.retry": "Retry read",
6
52
  "tabs.jobs": "Jobs",
7
53
  "settings.section.jobs": "Jobs & schedules",
54
+ "jobs.builtinSync": "Built-in Project sync · safe fast-forward only",
55
+ "jobs.automatic": "Automatic sync preference",
56
+ "jobs.syncEvery": "Sync interval (milliseconds, minimum 60000)",
57
+ "jobs.enabled": "On",
58
+ "jobs.disabled": "Off",
59
+ "jobs.result": "Business result",
8
60
  "jobs.heading": "Jobs & schedules",
9
- "jobs.help": "Run now starts one execution without changing the cadence. Cancel stops only the selected Run. Disable prevents future scheduled admissions. Operations use the current Operator binding.",
61
+ "jobs.help": "Ownership is separate from execution target. Run now admits one execution; disable prevents future admissions. Cancellation does not prove business or process termination. Global management needs no Operator Session; Task management retains its current authority checks.",
62
+ "jobs.executor": "Execution method",
63
+ "jobs.executionTask": "Open execution Task",
64
+ "jobs.sourceJob": "Created by Job / Run",
65
+ "jobs.taskCategory": "Task category",
66
+ "jobs.category.ordinary": "Ordinary Tasks",
67
+ "jobs.category.job": "Job execution Tasks",
68
+ "jobs.category.all": "All Tasks",
69
+ "jobs.recentSuccess": "Most recent successful Run",
70
+ "jobs.diagnostics": "Execution diagnostics",
71
+ "jobs.artifacts": "Registered fixed artifacts",
72
+ "jobs.registeredOnly": "Only the exact registered owner, Run, commit, path and digest are readable. Linked images must also be registered.",
73
+ "jobs.inspectSaved": "Inspect saved / unregistered evidence",
74
+ "jobs.register": "Register exact saved artifact",
75
+ "jobs.registerHelp": "Registration publishes this saved evidence; it does not rerun work. Saved but unregistered files are not yet formal results.",
76
+ "jobs.cancelBoundary": "Request cancellation of this exact Run only. This does not stop its execution Task or interrupt a shared Agent Session. Inspect the result for confirmation.",
77
+ "jobs.inspectSession": "Inspect Job-owned Session",
78
+ "jobs.stopSession": "Request stop of selected Session",
79
+ "jobs.newSession": "Replace stopped Session",
80
+ "jobs.sessionBoundary": "Only this Job-owned Session is targeted. Stop is a cleanup request; replacement requires proven termination and settled input. Neither operation replays work or removes evidence.",
81
+ "jobs.stale": "Refresh failed. Any retained facts are from the previous observation, not live success.",
82
+ "jobs.unavailable": "Selected Job is unavailable in this management scope.",
10
83
  "jobs.details": "Details",
11
84
  "jobs.scope": "Ownership",
12
85
  "jobs.project": "Target Project",
@@ -34,7 +107,7 @@ export const EN = {
34
107
  "jobs.unknown": "Submission outcome is unknown; it will not be resent. Inspect current facts and the original operation before taking another action.",
35
108
  "jobs.empty": "No Jobs in this scope. Creating a Job does not run it.",
36
109
  "jobs.configuration": "Execution configuration",
37
- "jobs.taskConfig": "Task-only shell specification: use the registered managed workspace, exact Project HEAD and owner. Commands and environment must not contain secrets. The server validates scope and execution prerequisites.",
110
+ "jobs.taskConfig": "Use the documented shell, Task delegation or Agent configuration. Shell requires an exact managed workspace/HEAD; new execution Tasks still require independent activation. Agent input grants no extra authority. No secrets. Executor kind is immutable.",
38
111
  "jobs.rebind": "Saving a Project refresh Job explicitly binds its current catalog target. It does not refresh the Project. No arbitrary global shell commands.",
39
112
  "jobs.rule": "Time rule",
40
113
  "jobs.instant": "Instant / interval anchor (ISO time with UTC offset)",
@@ -1,10 +1,83 @@
1
1
  // Simplified Chinese UI copy. Typed against the English catalog, so a key that
2
2
  // does not exist in en.ts is a compile error; a missing key falls back to en.
3
3
  export const ZH_CN = {
4
+ "settings.section.permissions": "命令权限",
5
+ "permissions.intro": "只读的命令发现模板,不是具体调用的授权结论。不会修改规则或 Grant。",
6
+ "permissions.notice": "按角色比较 CLI 发现范围。“可见”仅表示默认帮助与补全包含该命令,不代表可以执行。选择角色不会切换你的身份。",
7
+ "permissions.commands": "命令 × 角色矩阵",
8
+ "permissions.capabilities": "内置能力契约",
9
+ "permissions.view": "视图",
10
+ "permissions.search": "查找命令或能力",
11
+ "permissions.role": "角色模板",
12
+ "permissions.category": "命令分类",
13
+ "permissions.allRoles": "比较全部角色模板",
14
+ "permissions.allCategories": "全部分类",
15
+ "permissions.configured": "已配置 GlobalRole",
16
+ "permissions.role.public": "终端 / 非管控会话",
17
+ "permissions.role.operator": "Operator",
18
+ "permissions.role.global": "GlobalRole",
19
+ "permissions.role.leader": "Task Leader",
20
+ "permissions.role.worker": "Task Worker",
21
+ "permissions.role.reviewer": "Task Reviewer",
22
+ "permissions.role.unbound": "未绑定身份",
23
+ "permissions.command": "命令",
24
+ "permissions.visible": "发现可见",
25
+ "permissions.hidden": "隐藏",
26
+ "permissions.tableHint": "仅展示发现范围 · 选择命令查看各角色用法与边界;可横向滚动比较角色。",
27
+ "permissions.reason.visible": "当前目录投影包含该命令。下方选项与 CLI 帮助使用相同的角色过滤规则。",
28
+ "permissions.reason.hidden": "该角色的目录中不展示此可执行命令。这不表示仅缺少一个 Grant。",
29
+ "permissions.unevaluated": "尚未进行具体授权评估;未检查目标、实时 Session 或 Grant。",
30
+ "permissions.boundaries": "执行仍按实际操作检查 Session、Task/生命周期、planning 或 delivery 权限、Assignment/Review、Project/工作区及资源/Grant 边界。目录不能枚举全部领域检查;不可根据可见性、命令名称或 query 效果推断已授权。",
31
+ "permissions.boundary.public": "普通终端与非 Yui 管控会话共用公共入口;这不赋予管控身份,也不授权未被请求的操作。",
32
+ "permissions.boundary.operator": "需要当前 Operator 绑定及领域操作检查。离线模板不能证明该绑定有效。",
33
+ "permissions.boundary.global": "已配置 GlobalRole 共用此发现范围,但不继承 Operator 权限;角色配置和具体领域范围仍是权威来源。",
34
+ "permissions.boundary.leader": "限自身 Task 及已捕获的 Project 范围。Task 激活不会将 planning Session 自动提升为 delivery。",
35
+ "permissions.boundary.assignment": "Worker 与 Reviewer 共用发现模板,但各自 Assignment/Review、冻结引用和可写工作区可能不同。",
36
+ "permissions.boundary.unbound": "不完整或冲突的管控身份不能回退为公共身份。历史或已撤销 Session 即使能看到离线帮助,执行时仍会被重新检查。",
37
+ "permissions.capabilityNotice": "直接展示现有 registry 的内置描述,不维护另一份 CLI 与能力映射。权限类别和必填输入是契约,不是角色授权;不展示 Task 私有插件描述或实时评估。",
38
+ "permissions.effect": "声明效果",
39
+ "permissions.requiredPermissions": "所需权限类别",
40
+ "permissions.requiredInputs": "必填目标输入",
41
+ "permissions.inputs": "全部输入字段",
42
+ "permissions.legend": "含义、限制与来源",
43
+ "permissions.states": "具体能力发现可能返回 hidden(隐藏)、requestable(可经限定渠道申请)或 authorized(已授权,执行时仍复核)。缺少具体输入时不提供 access 结论;此页不会将角色与命令组合标为已授权或可申请。",
44
+ "permissions.sources": "来源",
45
+ "permissions.previous": "上一页",
46
+ "permissions.next": "下一页",
47
+ "permissions.loading": "正在读取模板…",
48
+ "permissions.empty": "没有匹配的模板。",
49
+ "permissions.retry": "重新读取",
4
50
  "tabs.jobs": "Job",
5
51
  "settings.section.jobs": "Job 与时间计划",
52
+ "jobs.builtinSync": "内置 Project 同步 · 仅安全快进",
53
+ "jobs.automatic": "自动同步偏好",
54
+ "jobs.syncEvery": "同步间隔(毫秒,最少 60000)",
55
+ "jobs.enabled": "开启",
56
+ "jobs.disabled": "关闭",
57
+ "jobs.result": "业务结果",
6
58
  "jobs.heading": "Job 与时间计划",
7
- "jobs.help": "立即运行只新增一次执行,不改变周期;取消仅处理选中的 Run;停用计划阻止后续触发。操作使用当前 Operator 绑定。",
59
+ "jobs.help": "管理归属与执行目标独立。立即运行只接纳一次执行;关闭计划阻止后续触发。取消不证明业务或进程已经停止。全局管理不要求 Operator Session 在线,Task 管理保留现有权限校验。",
60
+ "jobs.executor": "执行方式",
61
+ "jobs.executionTask": "打开执行 Task",
62
+ "jobs.sourceJob": "创建来源 Job / Run",
63
+ "jobs.taskCategory": "任务分类",
64
+ "jobs.category.ordinary": "普通任务",
65
+ "jobs.category.job": "Job 执行任务",
66
+ "jobs.category.all": "全部任务",
67
+ "jobs.recentSuccess": "最近成功的 Run",
68
+ "jobs.diagnostics": "执行诊断",
69
+ "jobs.artifacts": "已登记固定产物",
70
+ "jobs.registeredOnly": "仅可读取已登记的准确归属、Run、commit、路径及摘要。引用图片也须独立登记。",
71
+ "jobs.inspectSaved": "检查已保存/未登记材料",
72
+ "jobs.register": "补登记准确产物",
73
+ "jobs.registerHelp": "补登记只发布已保存证据,不重跑业务;已保存但未登记的文件尚非正式结果。",
74
+ "jobs.cancelBoundary": "仅请求取消此准确 Run,不停止其执行 Task 或中断共享 Agent Session。请检查结果确认实际状态。",
75
+ "jobs.inspectSession": "检查 Job 自有 Session",
76
+ "jobs.stopSession": "请求停止选中 Session",
77
+ "jobs.newSession": "替换已停止 Session",
78
+ "jobs.sessionBoundary": "仅作用于此 Job 自有 Session。停止是清理请求;替换要求旧实例确已终止且输入已结算。两者都不会重放业务或删除证据。",
79
+ "jobs.stale": "刷新失败,保留内容来自上次观察,不代表实时成功。",
80
+ "jobs.unavailable": "选中的 Job 在此管理范围内不可用。",
8
81
  "jobs.details": "详情",
9
82
  "jobs.scope": "所属范围",
10
83
  "jobs.project": "目标 Project",
@@ -32,7 +105,7 @@ export const ZH_CN = {
32
105
  "jobs.unknown": "提交结果未知,不会重发。请先检查当前事实和原操作,再决定后续动作。",
33
106
  "jobs.empty": "此范围暂无 Job。创建定义不会立即执行。",
34
107
  "jobs.configuration": "执行配置",
35
- "jobs.taskConfig": "仅 Task 可配置 shell:使用已登记的受管工作区、准确 Project HEAD 和 owner。命令与环境变量不得包含凭据;服务端校验范围和执行前提。",
108
+ "jobs.taskConfig": "使用已文档化的 shell、Task 委托或 Agent 配置。shell 要求准确受管工作区及 HEAD;新执行 Task 仍需独立激活。Agent 输入不扩展权限。禁止包含凭据;执行器类型不可改写。",
36
109
  "jobs.rebind": "保存 Project 刷新 Job 会显式绑定当前目录中的目标,但不会执行刷新。全局不开放任意 shell 命令。",
37
110
  "jobs.rule": "时间规则",
38
111
  "jobs.instant": "执行时间/周期锚点(带 UTC 偏移的 ISO 时间)",
@@ -61,6 +61,7 @@ import { TASK_VIEW_SCRIPT } from "./app/taskView.js";
61
61
  import { SETTINGS_SCRIPT } from "./app/settings.js";
62
62
  import { SKILLS_SCRIPT } from "./views/skills.js";
63
63
  import { JOBS_SCRIPT } from "./views/jobs.js";
64
+ import { PERMISSIONS_SCRIPT } from "./views/permissions.js";
64
65
  import { MODELS_VIEW_SCRIPT } from "./views/models.js";
65
66
  import { MODELS_SCRIPT } from "./app/models.js";
66
67
  import { MANAGE_SHELL_SCRIPT } from "./layout/manageShell.js";
@@ -69,6 +70,7 @@ export { APP_SCRIPT } from "./app/main.js";
69
70
  export const CLIENT_MODULES = Object.freeze({
70
71
  "views/skills": SKILLS_SCRIPT,
71
72
  "views/jobs": JOBS_SCRIPT,
73
+ "views/permissions": PERMISSIONS_SCRIPT,
72
74
  "views/models": MODELS_VIEW_SCRIPT,
73
75
  "lib/dom": DOM_SCRIPT,
74
76
  "lib/format": FORMAT_SCRIPT,
@@ -21,9 +21,16 @@ export function readManageRoute(url) {
21
21
  const section = (SECTIONS[area].find(item => item.section === requested) ||
22
22
  (area === "models" && requested === "connection" ? SECTIONS.settings.at(-1) : SECTIONS[area][0])).section;
23
23
  return { page: section === "connection" ? "models" : area, section,
24
+ ...(section === "jobs" ? { job: url.searchParams.get("job"), jobRun: url.searchParams.get("jobRun"),
25
+ artifact: url.searchParams.get("artifact") } : {}),
24
26
  source: url.searchParams.get("source") === "command-code" ? "command-code" : "factory" };
25
27
  }
26
28
  export function manageHref(next) {
29
+ if (next.section === "jobs") {
30
+ const query = new URLSearchParams();
31
+ for (const key of ["job", "jobRun", "artifact"]) if (next[key]) query.set(key, next[key]);
32
+ return "/settings/jobs" + (query.size ? "?" + query : "");
33
+ }
27
34
  return "/" + manageArea(next.page, next.section) + "/" + next.section +
28
35
  (next.section === "quota" && next.source === "command-code" ? "?source=command-code" : "");
29
36
  }
@@ -81,6 +88,8 @@ export function startManagement() {
81
88
  if (!link || link.target || link.hasAttribute("download") || event.defaultPrevented ||
82
89
  event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
83
90
  const url = new URL(link.href);
91
+ // Exact Job/Run links initialize a new pinned view, not just a section switch.
92
+ if (url.searchParams.has("job")) return;
84
93
  if (url.origin !== location.origin || !/^\/(models|settings)(\/|$)/.test(url.pathname)) return;
85
94
  event.preventDefault();
86
95
  navigateManage(readManageRoute(url));
@@ -23,6 +23,7 @@ export function syncUrl(state, replace) {
23
23
  if (state.query) params.set("q", state.query); else params.delete("q");
24
24
  if (state.attentionFilter) params.set("attention", state.attentionFilter); else params.delete("attention");
25
25
  if (state.catalogAll) params.set("all", "true"); else params.delete("all");
26
+ if (state.source && state.source !== "ordinary") params.set("source", state.source); else params.delete("source");
26
27
  applyUrl(params, replace);
27
28
  }
28
29