@gentbajko/slopify 3.17.0 → 3.18.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 (136) hide show
  1. package/dist/edge/http/actions.js +2 -2
  2. package/dist/edge/http/actor.js +10 -0
  3. package/dist/edge/http/app.js +21 -0
  4. package/dist/edge/http/checkpoints.js +14 -6
  5. package/dist/edge/http/external-reviews.js +95 -0
  6. package/dist/edge/http/projects.js +36 -10
  7. package/dist/edge/http/reviews.js +53 -18
  8. package/dist/edge/http/revisions.js +5 -1
  9. package/dist/edge/http/settings.js +9 -1
  10. package/dist/edge/http/starts.js +41 -0
  11. package/dist/edge/mcp/attribution.js +38 -0
  12. package/dist/edge/mcp/call-route.js +203 -0
  13. package/dist/edge/mcp/excluded-routes.js +95 -0
  14. package/dist/edge/mcp/file-tools.js +141 -0
  15. package/dist/edge/mcp/http.js +79 -0
  16. package/dist/edge/mcp/instructions.js +17 -0
  17. package/dist/edge/mcp/library-tools.js +143 -0
  18. package/dist/edge/mcp/remake-tools.js +90 -0
  19. package/dist/edge/mcp/review-tools.js +64 -0
  20. package/dist/edge/mcp/route-tool.js +61 -0
  21. package/dist/edge/mcp/server.js +48 -0
  22. package/dist/edge/mcp/state-shapes.js +60 -0
  23. package/dist/edge/mcp/tools-channels.js +153 -0
  24. package/dist/edge/mcp/tools-library.js +151 -0
  25. package/dist/edge/mcp/tools-planning.js +137 -0
  26. package/dist/edge/mcp/tools-projects.js +167 -0
  27. package/dist/edge/mcp/tools-publishing.js +148 -0
  28. package/dist/edge/mcp/tools-runs.js +94 -0
  29. package/dist/edge/mcp/tools-schedules.js +143 -0
  30. package/dist/edge/mcp/tools-setup.js +234 -0
  31. package/dist/edge/mcp/tools-storage.js +99 -0
  32. package/dist/edge/mcp/tools.js +21 -0
  33. package/dist/extension/slopify-studio-chrome.zip +0 -0
  34. package/dist/extension/slopify-studio-firefox.zip +0 -0
  35. package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
  36. package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
  37. package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
  38. package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
  39. package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
  40. package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
  41. package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
  42. package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
  43. package/dist/kernel/pipeline.js +6 -0
  44. package/dist/kernel/runner/graph.js +37 -2
  45. package/dist/kernel/runner/index.js +6 -3
  46. package/dist/main.js +29 -13
  47. package/dist/patch-notes/3.18.0.md +13 -0
  48. package/dist/patch-notes/index.json +6 -0
  49. package/dist/slices/admission/admitted-work.js +53 -0
  50. package/dist/slices/admission/heads.js +5 -0
  51. package/dist/slices/admission/repo.js +47 -29
  52. package/dist/slices/admission/schema.js +1 -0
  53. package/dist/slices/admission/stage-writes.js +21 -0
  54. package/dist/slices/admission/start.js +2 -1
  55. package/dist/slices/admission/state-words.js +110 -0
  56. package/dist/slices/agents/attention-work.js +233 -0
  57. package/dist/slices/agents/attention.js +168 -0
  58. package/dist/slices/agents/connection.js +15 -0
  59. package/dist/slices/agents/model.js +29 -0
  60. package/dist/slices/agents/repo.js +66 -0
  61. package/dist/slices/cancel/index.js +4 -1
  62. package/dist/slices/control/index.js +2 -1
  63. package/dist/slices/episodes/summarize.js +2 -1
  64. package/dist/slices/estimate/index.js +6 -2
  65. package/dist/slices/images/run.js +2 -1
  66. package/dist/slices/library/transfer.js +69 -0
  67. package/dist/slices/narration/run.js +2 -1
  68. package/dist/slices/notifications/address.js +40 -0
  69. package/dist/slices/notifications/notifier.js +43 -10
  70. package/dist/slices/notifications/rules.js +111 -45
  71. package/dist/slices/notifications/server.js +14 -0
  72. package/dist/slices/notifications/settings.js +1 -1
  73. package/dist/slices/notifications/subject.js +30 -0
  74. package/dist/slices/notifications/told.js +33 -0
  75. package/dist/slices/play-drafts/readiness.js +9 -2
  76. package/dist/slices/play-drafts/schema.js +2 -1
  77. package/dist/slices/rebuild/narration-words.js +76 -0
  78. package/dist/slices/rebuild/recipe-model.js +1 -0
  79. package/dist/slices/rebuild/recipe-reviews.js +44 -23
  80. package/dist/slices/rebuild/recovery-repo.js +0 -20
  81. package/dist/slices/rebuild/recovery-selection.js +14 -3
  82. package/dist/slices/rebuild/recovery.js +5 -2
  83. package/dist/slices/rebuild/remake-outdated.js +27 -0
  84. package/dist/slices/rebuild/repo.js +7 -2
  85. package/dist/slices/rebuild/review-redo.js +12 -0
  86. package/dist/slices/rebuild/runtime-agent-review.js +121 -0
  87. package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
  88. package/dist/slices/rebuild/runtime-review.js +12 -2
  89. package/dist/slices/rebuild/runtime-run.js +11 -2
  90. package/dist/slices/rebuild/runtime-store.js +32 -5
  91. package/dist/slices/reruns/index.js +2 -1
  92. package/dist/slices/research/run.js +2 -1
  93. package/dist/slices/reviews/external-model.js +21 -0
  94. package/dist/slices/reviews/external-release.js +38 -0
  95. package/dist/slices/reviews/external-repo.js +109 -0
  96. package/dist/slices/reviews/external-rows.js +68 -0
  97. package/dist/slices/reviews/external-waiting.js +75 -0
  98. package/dist/slices/reviews/holds.js +144 -0
  99. package/dist/slices/reviews/model.js +14 -0
  100. package/dist/slices/reviews/repo.js +27 -10
  101. package/dist/slices/reviews/rules.js +5 -3
  102. package/dist/slices/reviews/verdict.js +18 -8
  103. package/dist/slices/revisions/repo.js +3 -0
  104. package/dist/slices/revisions/schema.js +1 -0
  105. package/dist/slices/schedules/agenda.js +47 -17
  106. package/dist/slices/schedules/prepare.js +6 -0
  107. package/dist/slices/schedules/schema.js +13 -1
  108. package/dist/slices/storage/consequences.js +27 -0
  109. package/dist/slices/storage/portable.js +3 -0
  110. package/dist/slices/storage/project-files.js +66 -0
  111. package/dist/slices/subtitles/prepare.js +2 -1
  112. package/dist/slices/uploads/repo.js +12 -6
  113. package/dist/slices/uploads/rules.js +5 -0
  114. package/dist/slices/video/write-export.js +1 -1
  115. package/dist/slices/video/write-subtitles.js +1 -1
  116. package/dist/tutorials/Agents-MCP.md +168 -0
  117. package/dist/tutorials/Calendar.md +2 -1
  118. package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
  119. package/dist/tutorials/Home-and-Projects.md +16 -9
  120. package/dist/tutorials/Home.md +1 -0
  121. package/dist/tutorials/Notifications.md +25 -6
  122. package/dist/tutorials/Project-Page.md +8 -2
  123. package/dist/tutorials/Publishing-to-YouTube.md +1 -0
  124. package/dist/tutorials/Recovery-and-Retries.md +17 -3
  125. package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
  126. package/dist/tutorials/Schedules.md +1 -1
  127. package/dist/tutorials/Settings-Reference.md +15 -0
  128. package/dist/tutorials/Troubleshooting.md +3 -1
  129. package/dist/tutorials/Your-First-Short.md +1 -0
  130. package/dist/tutorials/_Sidebar.md +1 -0
  131. package/dist/web/assets/{index-DpdXRssU.css → index-BKbZIxAq.css} +1 -1
  132. package/dist/web/assets/index-Drth_MfQ.js +325 -0
  133. package/dist/web/assets/{pdf-B42WW32R.js → pdf-CQFUyijy.js} +1 -1
  134. package/dist/web/index.html +2 -2
  135. package/package.json +1 -1
  136. package/dist/web/assets/index-CbmBh8V-.js +0 -325
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { reviewStages } from "./model.js";
2
+ import { actors } from "../agents/model.js";
3
+ import { reviewAnswerers, reviewStages } from "./model.js";
3
4
  const rowSchema = z.object({
4
5
  id: z.string(),
5
6
  project_id: z.string(),
@@ -10,12 +11,14 @@ const rowSchema = z.object({
10
11
  review_fingerprint: z.string(),
11
12
  passed: z.number(),
12
13
  reasons: z.string(),
13
- outcome: z.enum(["passed", "flagged", "redo"]),
14
+ outcome: z.enum(["passed", "flagged", "redo", "auto-passed"]),
14
15
  attempt: z.number().int(),
15
16
  action: z.enum(["overruled", "redone"]).nullable(),
16
17
  action_at: z.string().nullable(),
17
18
  redo_state: z.enum(["pending", "started", "failed"]).nullable(),
18
19
  redo_error: z.string().nullable(),
20
+ answered_by: z.enum(reviewAnswerers),
21
+ action_by: z.enum(actors).nullable(),
19
22
  created_at: z.string(),
20
23
  });
21
24
  function recordOf(value) {
@@ -36,18 +39,21 @@ function recordOf(value) {
36
39
  actionAt: row.action_at,
37
40
  redoState: row.redo_state,
38
41
  redoError: row.redo_error,
42
+ answeredBy: row.answered_by,
43
+ actionBy: row.action_by,
39
44
  createdAt: row.created_at,
40
45
  };
41
46
  }
42
47
  // One verdict per review step: the same review asked again (a retried step whose answer was
43
48
  // saved but not published) replaces the earlier answer rather than adding a second one.
44
49
  export function saveVerdict(db, record) {
45
- db.prepare(`INSERT INTO review_verdicts(id,project_id,revision_id,item_key,stage,item_fingerprint,review_fingerprint,passed,reasons,outcome,attempt,action,action_at,redo_state,redo_error,created_at)
46
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
50
+ db.prepare(`INSERT INTO review_verdicts(id,project_id,revision_id,item_key,stage,item_fingerprint,review_fingerprint,passed,reasons,outcome,attempt,action,action_at,redo_state,redo_error,answered_by,action_by,created_at)
51
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
47
52
  ON CONFLICT(project_id,review_fingerprint) DO UPDATE SET revision_id=excluded.revision_id,
48
53
  item_fingerprint=excluded.item_fingerprint,passed=excluded.passed,reasons=excluded.reasons,
49
- outcome=excluded.outcome,attempt=excluded.attempt,action=NULL,action_at=NULL,
50
- redo_state=excluded.redo_state,redo_error=NULL,created_at=excluded.created_at`).run(record.id, record.projectId, record.revisionId, record.itemKey, record.stage, record.itemFingerprint, record.reviewFingerprint, record.passed ? 1 : 0, JSON.stringify(record.reasons), record.outcome, record.attempt, record.action, record.actionAt, record.redoState, record.redoError, record.createdAt);
54
+ outcome=excluded.outcome,attempt=excluded.attempt,action=NULL,action_at=NULL,action_by=NULL,
55
+ redo_state=excluded.redo_state,redo_error=NULL,answered_by=excluded.answered_by,
56
+ created_at=excluded.created_at`).run(record.id, record.projectId, record.revisionId, record.itemKey, record.stage, record.itemFingerprint, record.reviewFingerprint, record.passed ? 1 : 0, JSON.stringify(record.reasons), record.outcome, record.attempt, record.action, record.actionAt, record.redoState, record.redoError, record.answeredBy, record.actionBy, record.createdAt);
51
57
  const saved = verdictByReview(db, record.projectId, record.reviewFingerprint);
52
58
  if (saved === undefined)
53
59
  throw new Error("Slopify hit an internal error (a review's verdict could not be saved). Try again; if it happens again, use Download diagnostics in Settings and report it.");
@@ -93,10 +99,13 @@ export function setRedoState(db, id, state, error = null) {
93
99
  // A passed verdict has nothing to overrule. Overrule also calls off an automatic redo that is
94
100
  // still waiting to start (what depends on the item then goes ahead with it as it is), but a
95
101
  // redo already under way owns the item until it lands.
96
- export function actOnVerdict(db, projectId, id, action, at) {
102
+ export function actOnVerdict(db, projectId, id, action, at, by = "person") {
97
103
  const record = verdictById(db, projectId, id);
98
104
  if (record === undefined)
99
105
  return { ok: false, reason: "not-found" };
106
+ // The first decision stands: a second person or agent is told it was already made.
107
+ if (record.action !== null)
108
+ return { ok: false, reason: "decided", value: record };
100
109
  if (action === "overruled" && record.passed)
101
110
  return { ok: false, reason: "conflict" };
102
111
  if (record.redoState === "started")
@@ -105,13 +114,21 @@ export function actOnVerdict(db, projectId, id, action, at) {
105
114
  if (action !== "overruled")
106
115
  return { ok: false, reason: "conflict" };
107
116
  const called = db
108
- .prepare("UPDATE review_verdicts SET action=?, action_at=?, redo_state=NULL WHERE id=? AND redo_state='pending'")
109
- .run(action, at, id);
117
+ .prepare("UPDATE review_verdicts SET action=?, action_at=?, action_by=?, redo_state=NULL WHERE id=? AND redo_state='pending' AND action IS NULL")
118
+ .run(action, at, by, id);
110
119
  if (Number(called.changes) !== 1)
111
120
  return { ok: false, reason: "conflict" };
112
121
  }
113
122
  else {
114
- db.prepare("UPDATE review_verdicts SET action=?, action_at=? WHERE id=?").run(action, at, id);
123
+ const changed = db
124
+ .prepare("UPDATE review_verdicts SET action=?, action_at=?, action_by=? WHERE id=? AND action IS NULL")
125
+ .run(action, at, by, id);
126
+ if (Number(changed.changes) !== 1) {
127
+ const now = verdictById(db, projectId, id);
128
+ return now === undefined
129
+ ? { ok: false, reason: "not-found" }
130
+ : { ok: false, reason: "decided", value: now };
131
+ }
115
132
  }
116
133
  const updated = verdictById(db, projectId, id);
117
134
  return updated === undefined ? { ok: false, reason: "not-found" } : { ok: true, value: updated };
@@ -1,4 +1,4 @@
1
- import { imageReviewers, reviewModeOf, reviewRetriesMax, reviewRetriesMin, reviewStageLabels, reviewStages, visionStages, } from "./model.js";
1
+ import { imageReviewers, reviewedByAgent, reviewModeOf, reviewRetriesMax, reviewRetriesMin, reviewStageLabels, reviewStages, visionStages, } from "./model.js";
2
2
  // A stage is reviewed only while it makes something: a provided article, uploaded images or
3
3
  // narration Off leave nothing for a reviewer to look at, so a review left on there asks for
4
4
  // nothing and holds nothing up.
@@ -39,7 +39,8 @@ export function reviewFields(draft) {
39
39
  if (active.length === 0 || reviews === undefined)
40
40
  return [];
41
41
  const fields = [];
42
- if (reviews.provider.trim() === "" || reviews.model.trim() === "")
42
+ const byAgent = reviewedByAgent(reviews);
43
+ if (!byAgent && (reviews.provider.trim() === "" || reviews.model.trim() === ""))
43
44
  fields.push({
44
45
  field: "reviews.provider",
45
46
  message: "Automatic reviews need a reviewer model. Choose one in the Reviews section, or set every review to Off.",
@@ -52,7 +53,8 @@ export function reviewFields(draft) {
52
53
  message: `Enter a number of redos between ${reviewRetriesMin} and ${reviewRetriesMax} in the Reviews section.`,
53
54
  });
54
55
  const pictures = active.filter((stage) => visionStages.includes(stage));
55
- if (pictures.length > 0 &&
56
+ if (!byAgent &&
57
+ pictures.length > 0 &&
56
58
  reviews.provider.trim() !== "" &&
57
59
  !reviewerSeesImages(reviews.provider))
58
60
  fields.push({
@@ -3,15 +3,28 @@ import { z } from "zod";
3
3
  // at least one reason, since a reason is what the person reads beside it.
4
4
  export const reviewReasonMax = 500;
5
5
  export const reviewReasonsMax = 10;
6
- const answerSchema = z
6
+ export const verdictAnswerSchema = z
7
7
  .object({
8
8
  verdict: z.enum(["pass", "fail"]),
9
- reasons: z.array(z.string().trim().min(1).max(reviewReasonMax)).max(reviewReasonsMax),
9
+ reasons: z
10
+ .array(z
11
+ .string()
12
+ .trim()
13
+ .min(1, "a reason can't be empty")
14
+ .max(reviewReasonMax, `a reason is at most ${String(reviewReasonMax)} characters`))
15
+ .max(reviewReasonsMax, `at most ${String(reviewReasonsMax)} reasons`),
10
16
  })
11
17
  .strict()
12
18
  .refine((answer) => answer.verdict === "pass" || answer.reasons.length > 0, {
13
19
  message: "a failed verdict names no reason",
14
20
  });
21
+ // What was wrong with a verdict, where it was: " (reasons.0): a reason can't be empty". The
22
+ // reviewer's refused answer and an agent's refused verdict say it the same way.
23
+ export function verdictIssue(issues, otherwise) {
24
+ const issue = issues[0];
25
+ const where = issue === undefined || issue.path.length === 0 ? "" : ` (${issue.path.map(String).join(".")})`;
26
+ return `${where}: ${issue?.message ?? otherwise}`;
27
+ }
15
28
  const answerRules = 'Answer with one JSON object and nothing else: {"verdict":"pass","reasons":[]} when the item is fine, or {"verdict":"fail","reasons":["..."]} with one short sentence per problem you found (at most 10). Name only real problems you can point to.';
16
29
  export function reviewMessages(material) {
17
30
  const attached = material.images.length === 0
@@ -55,15 +68,12 @@ export function parseVerdict(text) {
55
68
  reason: `The reviewer's answer was not the JSON verdict Slopify asked for. ${retryHint}`,
56
69
  };
57
70
  }
58
- const parsed = answerSchema.safeParse(value);
59
- if (!parsed.success) {
60
- const issue = parsed.error.issues[0];
61
- const where = issue === undefined || issue.path.length === 0 ? "" : ` (${issue.path.join(".")})`;
71
+ const parsed = verdictAnswerSchema.safeParse(value);
72
+ if (!parsed.success)
62
73
  return {
63
74
  ok: false,
64
- reason: `The reviewer's verdict was malformed${where}: ${issue?.message ?? "it did not match the verdict format"}. ${retryHint}`,
75
+ reason: `The reviewer's verdict was malformed${verdictIssue(parsed.error.issues, "it did not match the verdict format")}. ${retryHint}`,
65
76
  };
66
- }
67
77
  return {
68
78
  ok: true,
69
79
  value: { passed: parsed.data.verdict === "pass", reasons: parsed.data.reasons },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { agentRevisionIds } from "../agents/repo.js";
2
3
  import { projectRevisionSchema } from "./schema.js";
3
4
  export { insertManifestOutput, insertManifestPiece, outputsForRevision, piecesForRevision, selectOutputRecord, selectPieceRecord, } from "./manifest-repo.js";
4
5
  const revisionRow = z.object({
@@ -37,6 +38,7 @@ export function currentRevisionId(db, projectId) {
37
38
  }
38
39
  export function listRevisionHistory(db, projectId) {
39
40
  const head = currentRevisionId(db, projectId);
41
+ const byAgent = agentRevisionIds(db, projectId);
40
42
  return db
41
43
  .prepare("SELECT * FROM project_revisions WHERE project_id=? ORDER BY created_at DESC,id DESC")
42
44
  .all(projectId)
@@ -49,6 +51,7 @@ export function listRevisionHistory(db, projectId) {
49
51
  title: revision.config.title,
50
52
  createdAt: revision.createdAt,
51
53
  current: revision.id === head,
54
+ ...(byAgent.has(revision.id) ? { byAgent: true } : {}),
52
55
  };
53
56
  });
54
57
  }
@@ -220,6 +220,7 @@ export const revisionSummarySchema = z.object({
220
220
  title: z.string(),
221
221
  createdAt: z.string(),
222
222
  current: z.boolean(),
223
+ byAgent: z.boolean().optional(),
223
224
  });
224
225
  export const revisionMutationSuccessSchema = z.object({
225
226
  ok: z.literal(true),
@@ -1,11 +1,14 @@
1
1
  import { z } from "zod";
2
- import { derive } from "../../kernel/runner/graph.js";
2
+ import { derive, progressOf } from "../../kernel/runner/graph.js";
3
3
  import { liveProject, stageStandingsByProject } from "../admission/repo.js";
4
+ import { readsByWaitBesideFailure } from "../admission/state-words.js";
4
5
  import { queueEntries } from "../batch/index.js";
5
6
  import { sampleIds } from "../onboarding/model.js";
6
7
  import { readSampleRecord } from "../onboarding/state.js";
8
+ import { projectHolds } from "../reviews/holds.js";
7
9
  import { limitWaitsByProject, listingWait } from "../run-cost/panel.js";
8
- import { uploadedProjects } from "../uploads/repo.js";
10
+ import { setAsideProjects, uploadedAndSettled, uploadedProjects } from "../uploads/repo.js";
11
+ import { settledStatus } from "../uploads/rules.js";
9
12
  import { nextOccurrence } from "./calendar.js";
10
13
  import { preparedProject } from "./prepare.js";
11
14
  import { scheduleRows } from "./repo.js";
@@ -109,21 +112,22 @@ const projectRow = z.object({
109
112
  finished_at: z.string().nullable(),
110
113
  });
111
114
  // Projects the person has something to decide on in the current revision: a review
112
- // checkpoint holding the run, or an automatic review that failed an item and waits for
113
- // Overrule or Redo (its latest verdict, with no redo under way).
114
- function awaitingReview(db) {
115
+ // checkpoint holding the run for them (`reviews/holds.ts`: reached, and not a Before Video hold
116
+ // its schedule added, which waits for its day), or an automatic review that failed an item and
117
+ // waits for Overrule or Redo (its latest verdict, with no redo under way), as needs_attention
118
+ // reads them.
119
+ function awaitingReview(db, holds) {
120
+ const checkpoints = [...holds.holding]
121
+ .filter(([, held]) => held.some((hold) => hold.waitsFor === "person"))
122
+ .map(([id]) => id);
115
123
  const ids = db
116
- .prepare(`SELECT c.project_id AS id FROM review_checkpoints c
117
- JOIN project_heads h ON h.project_id=c.project_id AND h.revision_id=c.revision_id
118
- WHERE c.state IN ('held','pending-review') AND c.approved_at IS NULL
119
- UNION
120
- SELECT v.project_id AS id FROM review_verdicts v
124
+ .prepare(`SELECT v.project_id AS id FROM review_verdicts v
121
125
  JOIN project_heads h ON h.project_id=v.project_id AND h.revision_id=v.revision_id
122
126
  WHERE v.passed=0 AND v.action IS NULL AND (v.redo_state IS NULL OR v.redo_state='failed')
123
127
  AND NOT EXISTS (SELECT 1 FROM review_verdicts n WHERE n.project_id=v.project_id
124
128
  AND n.item_key=v.item_key AND (n.created_at>v.created_at OR (n.created_at=v.created_at AND n.rowid>v.rowid)))`)
125
129
  .all();
126
- return new Set(ids.map((row) => z.string().parse(row.id)));
130
+ return new Set([...checkpoints, ...ids.map((row) => z.string().parse(row.id))]);
127
131
  }
128
132
  // Whether a finished project's settings make a video: projects stored before sources were
129
133
  // recorded made one.
@@ -137,13 +141,20 @@ function sampleProjectIds(db) {
137
141
  }));
138
142
  }
139
143
  // What a project on the calendar asks of the person, if anything. The same readings Home's
140
- // Needs you uses: a failed or paused run, or one waiting for a review.
141
- function needsOf(state, reviewing) {
144
+ // Needs you uses: a failed run, a paused run, one a restart stopped (Continue the run), or one
145
+ // waiting for a review. A step failed beside a wait reads by that wait and is listed apart
146
+ // (`stepFailed`). One Kept as is asks nothing, nor does an uploaded one that reads Done
147
+ // (`uploadedAndSettled`).
148
+ function needsOf(state, ask) {
142
149
  if (state === "failed")
143
150
  return "failed";
144
151
  if (state === "paused")
145
152
  return "paused";
146
- if (reviewing && state !== "canceled")
153
+ if (ask.aside || ask.settled)
154
+ return undefined;
155
+ if (ask.stopped)
156
+ return "stopped";
157
+ if (ask.reviewing && state !== "canceled")
147
158
  return "review";
148
159
  return undefined;
149
160
  }
@@ -151,10 +162,13 @@ function needsOf(state, reviewing) {
151
162
  // the range, with what each needs from the person and whether its video is ready to upload.
152
163
  function projectsIn(deps, from, to) {
153
164
  const standings = stageStandingsByProject(deps.db);
154
- const reviewing = awaitingReview(deps.db);
165
+ const settled = uploadedAndSettled(deps.db);
166
+ const holds = projectHolds(deps.db, { standings, settled });
167
+ const reviewing = awaitingReview(deps.db, holds);
155
168
  const uploads = uploadedProjects(deps.db);
156
169
  const samples = sampleProjectIds(deps.db);
157
170
  const waits = limitWaitsByProject(deps.db);
171
+ const aside = setAsideProjects(deps.db);
158
172
  const fromSchedule = new Map();
159
173
  for (const row of deps.db
160
174
  .prepare(`SELECT DISTINCT admitted.value AS project_id, schedule_runs.schedule_id
@@ -180,14 +194,22 @@ function projectsIn(deps, from, to) {
180
194
  .all(to.toISOString(), from.toISOString())
181
195
  .flatMap((row) => {
182
196
  const project = projectRow.parse(row);
183
- const state = derive(standings.get(project.id) ?? [], project.paused === 1);
197
+ const state = settledStatus(derive(standings.get(project.id) ?? [], project.paused === 1), settled.has(project.id));
184
198
  const terminal = state === "done" || state === "partial" || state === "failed" || state === "canceled";
185
199
  if (terminal) {
186
200
  const ended = project.finished_at ?? project.created_at;
187
201
  if (ended < from.toISOString() || ended >= to.toISOString())
188
202
  return [];
189
203
  }
190
- const needs = needsOf(state, reviewing.has(project.id));
204
+ // One Kept as is waits for nobody. A run parked on an agent's review holds no checkpoint
205
+ // of the person's, but an item already flagged still waits for their Overrule or Redo.
206
+ const waitingForAgent = holds.waitingForAgent.has(project.id);
207
+ const needs = needsOf(state, {
208
+ stopped: holds.stopped.has(project.id),
209
+ reviewing: reviewing.has(project.id),
210
+ aside: aside.has(project.id),
211
+ settled: settled.has(project.id),
212
+ });
191
213
  const ready = (state === "done" || state === "partial") &&
192
214
  makesVideo(project.video_source) &&
193
215
  !uploads.has(project.id) &&
@@ -201,8 +223,16 @@ function projectsIn(deps, from, to) {
201
223
  createdAt: project.created_at,
202
224
  finishedAt: terminal ? (project.finished_at ?? project.created_at) : null,
203
225
  scheduleId: fromSchedule.get(project.id) ?? null,
226
+ progress: progressOf(standings.get(project.id) ?? []),
204
227
  ...(needs === undefined ? {} : { needs }),
205
228
  ...(ready ? { readyToUpload: true } : {}),
229
+ ...(aside.has(project.id) ? { setAside: true } : {}),
230
+ ...(holds.prepared.has(project.id) ? { prepared: true } : {}),
231
+ ...(waitingForAgent ? { waitingForAgent: true } : {}),
232
+ ...(holds.stopped.has(project.id) ? { stopped: true } : {}),
233
+ ...(readsByWaitBesideFailure(state, standings.get(project.id) ?? [])
234
+ ? { stepFailed: true }
235
+ : {}),
206
236
  ...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
207
237
  },
208
238
  ];
@@ -154,3 +154,9 @@ export function preparedTopics(deps, scheduleId) {
154
154
  return found === undefined ? [] : [{ title, topic: topic.title, projectId: found.projectId }];
155
155
  });
156
156
  }
157
+ // A project a schedule prepared ahead whose Before Video hold is the schedule's own: that hold
158
+ // waits for its day, not for anyone (`reviews/holds.ts` reads it with the project's other
159
+ // checkpoints). The day deletes the row (`continuePrepared`).
160
+ export function heldForItsDay(alias) {
161
+ return `EXISTS (SELECT 1 FROM prepared_videos p WHERE p.project_id=${alias}.project_id AND p.added_checkpoint=1)`;
162
+ }
@@ -5,7 +5,7 @@ import { cadenceSchema, validTimeZone } from "./calendar.js";
5
5
  const id = z.uuid();
6
6
  // What a project on the calendar waits on the person for: a failed or paused run, or a review
7
7
  // (a checkpoint holding the run, or an automatic review's failed item).
8
- export const calendarNeeds = ["failed", "paused", "review"];
8
+ export const calendarNeeds = ["failed", "paused", "stopped", "review"];
9
9
  const keywordName = z
10
10
  .string()
11
11
  .min(1)
@@ -242,10 +242,22 @@ export const calendarProjectSchema = z
242
242
  createdAt: z.string(),
243
243
  finishedAt: z.string().nullable(),
244
244
  scheduleId: z.string().nullable(),
245
+ // The run's share done (`progressOf`), which tells a held run from a queued one. Older
246
+ // servers leave it out.
247
+ progress: z.number().min(0).max(1).default(0),
245
248
  // What the project waits on the person for; left out when nothing.
246
249
  needs: z.enum(calendarNeeds).optional(),
247
250
  // Finished, makes a video, not marked uploaded and not a bundled sample.
248
251
  readyToUpload: z.literal(true).optional(),
252
+ // What a held run waits for when it isn't the person (`reviews/holds.ts`), and a run
253
+ // Kept as is, as the Projects list says them.
254
+ setAside: z.literal(true).optional(),
255
+ prepared: z.literal(true).optional(),
256
+ waitingForAgent: z.literal(true).optional(),
257
+ stopped: z.literal(true).optional(),
258
+ // A step failed while the run still reads by a wait (`readsByWaitBesideFailure`): listed as a
259
+ // failed item beside what it waits for, as on Home. Older servers leave it out.
260
+ stepFailed: z.literal(true).optional(),
249
261
  // Stages waiting for a CLI plan's limits to reset.
250
262
  limitWaits: z
251
263
  .array(z
@@ -0,0 +1,27 @@
1
+ // What a storage action removes and what it costs later, in the words its confirm dialog
2
+ // uses: shared by the web page (Settings → Backup & storage, a project's Free up space) and
3
+ // the agent's tool, so both state the same count and size.
4
+ // "512 B", "3.4 MB", "12 GB": binary units, one decimal under ten.
5
+ export function fileSize(bytes) {
6
+ if (bytes < 1024)
7
+ return `${String(bytes)} B`;
8
+ const units = ["KB", "MB", "GB", "TB"];
9
+ let value = bytes;
10
+ let unit = units[0];
11
+ for (const candidate of units) {
12
+ value /= 1024;
13
+ unit = candidate;
14
+ if (value < 1024 || candidate === units.at(-1))
15
+ break;
16
+ }
17
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1).replace(/\.0$/, "")} ${unit}`;
18
+ }
19
+ // What Delete old versions removes and what it costs later; without the numbers when they
20
+ // can't be read. `scope` is whose History it empties: every project's, or one project's.
21
+ export function oldVersionsConsequence(size, scope = "projects") {
22
+ const what = size === undefined
23
+ ? "the older versions"
24
+ : `${String(size.files)} file(s) of older versions and frees ${fileSize(size.bytes)}`;
25
+ const where = scope === "project" ? "this project's History folder" : "the projects' History folders";
26
+ return `This deletes ${what}: what is in ${where} (earlier renders, narration before a change). The current video, shorts, thumbnails and everything in Upload stay, and so does anything you uploaded or put there yourself. Going back to one of those older versions makes its files again, which takes time and uses provider credits.`;
27
+ }
@@ -8,6 +8,7 @@ import { transact } from "../../kernel/db/tx.js";
8
8
  import { liveProject } from "../admission/repo.js";
9
9
  import { silenceGapSecondsMax } from "../admission/rules.js";
10
10
  import { detectSlots } from "../admission/substitute.js";
11
+ import { agentConnectionKey } from "../agents/connection.js";
11
12
  import { fontMaxBytes } from "../fonts/model.js";
12
13
  import { readFontMetadata } from "../fonts/sfnt.js";
13
14
  import { lintEntry, lintPrompt } from "../library/lint.js";
@@ -373,9 +374,11 @@ function portableSettings(settings) {
373
374
  // So does the Studio extension's pairing: its token reads every project's upload pack.
374
375
  // Which "What's new" tour and which patch notes this install has closed are about this
375
376
  // install, not the data.
377
+ // When an agent last connected is this install's own fact.
376
378
  if (key === "tutorial.session" ||
377
379
  // Whether this install showed its first-run welcome belongs to this install.
378
380
  key === "first-run.done" ||
381
+ key === agentConnectionKey ||
379
382
  key === notificationUrlKey ||
380
383
  key === studioPairingKey ||
381
384
  // What waits to be filled in Studio belongs to this install's pairing.
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { desiredPlaces } from "./arrange.js";
3
+ import { assetOf } from "./asset-name.js";
4
+ import { outputPath } from "./layout.js";
5
+ import { outputsOf, projectTitle } from "./repo.js";
6
+ // A project's files as its folder is arranged (`arrange.ts`): Upload (what is published),
7
+ // Working (what it was made from) and History (older versions). The place is where the file
8
+ // belongs even before the folder is arranged; `path` is where it is now and can move, so the
9
+ // `/files` URL is the handle to keep.
10
+ export const fileGroups = ["Upload", "Working", "History"];
11
+ export function groupOf(place) {
12
+ const first = place.split("/", 1)[0];
13
+ return first === "Upload" || first === "History" ? first : "Working";
14
+ }
15
+ const recordRow = z.object({
16
+ path: z.string(),
17
+ revision_id: z.string(),
18
+ id: z.string(),
19
+ role: z.string().nullable(),
20
+ });
21
+ // The newest version record of each stored path, for its `/files/:id/revisions/...` URL.
22
+ function revisionRecords(db, projectId) {
23
+ const records = new Map();
24
+ for (const row of db
25
+ .prepare(`SELECT a.path, o.revision_id, o.id, json_extract(o.descriptor,'$.role') AS role, o.created_at
26
+ FROM revision_outputs o JOIN project_assets a ON a.id=o.asset_id AND a.project_id=o.project_id
27
+ WHERE o.project_id=?
28
+ UNION ALL
29
+ SELECT a.path, p.revision_id, p.id, 'piece:'||COALESCE(json_extract(p.descriptor,'$.kind'),'other'), p.created_at
30
+ FROM revision_pieces p JOIN project_assets a ON a.id=p.asset_id AND a.project_id=p.project_id
31
+ WHERE p.project_id=?
32
+ ORDER BY 5`)
33
+ .all(projectId, projectId)) {
34
+ const record = recordRow.parse(row);
35
+ records.set(record.path, record);
36
+ }
37
+ return records;
38
+ }
39
+ export function projectFiles(deps, projectId) {
40
+ if (projectTitle(deps.db, projectId) === undefined)
41
+ return undefined;
42
+ const current = new Map(outputsOf(deps.db, projectId).map((output) => [output.path, output]));
43
+ const records = revisionRecords(deps.db, projectId);
44
+ return [...desiredPlaces(deps.db, projectId)]
45
+ .flatMap(([stored, place]) => {
46
+ const output = place.startsWith("History/") ? undefined : current.get(stored);
47
+ const record = records.get(stored);
48
+ const url = output !== undefined
49
+ ? `/files/${projectId}/${assetOf(output)}`
50
+ : record === undefined
51
+ ? undefined
52
+ : `/files/${projectId}/revisions/${record.revision_id}/${record.id}`;
53
+ if (url === undefined)
54
+ return [];
55
+ return [
56
+ {
57
+ group: groupOf(place),
58
+ place,
59
+ role: output?.role ?? record?.role ?? "other",
60
+ path: outputPath(deps.paths, projectId, stored),
61
+ url,
62
+ },
63
+ ];
64
+ })
65
+ .toSorted((a, b) => fileGroups.indexOf(a.group) - fileGroups.indexOf(b.group) || a.place.localeCompare(b.place));
66
+ }
@@ -4,7 +4,8 @@ import { join, relative, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { subtitleModelDir } from "../../kernel/paths.js";
6
6
  import { projectLanguage } from "../../kernel/ports/languages.js";
7
- import { projectById, setStageProgress } from "../admission/repo.js";
7
+ import { projectById } from "../admission/repo.js";
8
+ import { setStageProgress } from "../admission/stage-writes.js";
8
9
  import { captionFont, captionFontDeps } from "../fonts/coverage.js";
9
10
  import { resolveFont } from "../fonts/index.js";
10
11
  import { outputPath, projectDir } from "../storage/layout.js";
@@ -1,3 +1,5 @@
1
+ import { admittedUnfinished } from "../admission/admitted-work.js";
2
+ import { oneProject } from "../admission/repo.js";
1
3
  export function markUploaded(db, projectId, uploaded, at) {
2
4
  if (db.prepare("SELECT 1 FROM projects WHERE id=?").get(projectId) === undefined)
3
5
  return { ok: false, reason: "not-found" };
@@ -18,15 +20,19 @@ export function uploadedProjects(db) {
18
20
  .all()
19
21
  .map((row) => [String(row.project_id), String(row.uploaded_at)]));
20
22
  }
21
- // Uploaded projects with nothing admitted still to run: their leftover steps (a placeholder
22
- // from an edit after the upload, a pause from a bulk Pause) are not work the person is
23
- // waiting for, so the listing calls them done rather than "Paused" or "Waiting".
24
- export function uploadedAndSettled(db) {
23
+ // Uploaded projects with nothing left to run read done, not "Paused" or "Waiting": a step still
24
+ // allowed to run, or a held one admitted for the current version (`admittedUnfinished`, as
25
+ // `reviews/holds.ts` heldBesideReviews reads it), keeps one unsettled. What is left otherwise
26
+ // - a placeholder from an edit after the upload, a pause from a bulk Pause - is not work the
27
+ // person waits for. One project's alone when `projectId` is given.
28
+ export function uploadedAndSettled(db, projectId) {
29
+ const one = oneProject("u", projectId);
25
30
  return new Set(db
26
31
  .prepare(`SELECT u.project_id FROM project_uploads u WHERE NOT EXISTS(
27
32
  SELECT 1 FROM revision_work w WHERE w.project_id=u.project_id
28
- AND w.state IN ('pending','running') AND w.dispatch_state!='held')`)
29
- .all()
33
+ AND w.state IN ('pending','running')
34
+ AND (w.dispatch_state!='held' OR ${admittedUnfinished("w")}))${one.sql}`)
35
+ .all(...one.params)
30
36
  .map((row) => String(row.project_id)));
31
37
  }
32
38
  // "Keep as is": a waiting run the person leaves as it is, until the project's next edit. Set on
@@ -0,0 +1,5 @@
1
+ // An uploaded, settled project (`uploadedAndSettled`) reads done where its run would read paused
2
+ // or pending: the Projects list, a project's page and the calendar.
3
+ export function settledStatus(status, settled) {
4
+ return settled && (status === "paused" || status === "pending") ? "done" : status;
5
+ }
@@ -1,7 +1,7 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { transact } from "../../kernel/db/tx.js";
4
- import { setStageProgress } from "../admission/repo.js";
4
+ import { setStageProgress } from "../admission/stage-writes.js";
5
5
  import { outputPath } from "../storage/layout.js";
6
6
  import { deleteOutput, insertOutput, outputsOf } from "../storage/repo.js";
7
7
  import { subtitleRoles } from "../subtitles/prepare.js";
@@ -1,6 +1,6 @@
1
1
  import { rmSync, statSync } from "node:fs";
2
2
  import { transact } from "../../kernel/db/tx.js";
3
- import { setStageProgress } from "../admission/repo.js";
3
+ import { setStageProgress } from "../admission/stage-writes.js";
4
4
  import { outputPath } from "../storage/layout.js";
5
5
  import { deleteOutput, insertOutput, outputsOf } from "../storage/repo.js";
6
6
  import { subtitleRoles } from "../subtitles/prepare.js";