copilot-operator 0.1.16 → 0.1.17
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.
- package/dist/src/api/operator.controller.js +23 -0
- package/dist/src/api/operator.service.js +32 -1
- package/dist/src/plan/brief.js +34 -0
- package/dist/src/plan/importPlan.js +8 -1
- package/dist/src/plan/schema.js +24 -0
- package/dist/src/plan/systemGuide.js +32 -0
- package/dist/src/session/exports.js +2 -0
- package/dist/src/session/store.js +68 -6
- package/dist/src/vcs/snapshot.js +354 -0
- package/dist/src/vcs/taskVcs.js +68 -23
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +1 -0
- package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +1 -0
- package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +1 -0
- package/dist/web/_next/static/css/{e2a2c9eb3c226313.css → c37978b44194c37b.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +5 -3
- package/dist/web/_next/static/chunks/549-7fd4fe96682299f5.js +0 -1
- package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +0 -1
- package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +0 -1
- /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → kkxFcGzA9Uo1uRAgcoDti}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → kkxFcGzA9Uo1uRAgcoDti}/_ssgManifest.js +0 -0
|
@@ -126,6 +126,10 @@ let OperatorController = class OperatorController {
|
|
|
126
126
|
sessions() {
|
|
127
127
|
return this.ops.listSessions();
|
|
128
128
|
}
|
|
129
|
+
/** The sessions list in the operator's order: every session id, top to bottom. */
|
|
130
|
+
reorderSessions(body) {
|
|
131
|
+
return this.ops.reorderSessions(body?.ids).catch(fail);
|
|
132
|
+
}
|
|
129
133
|
createSession(body) {
|
|
130
134
|
return this.ops.createSession(body?.name ?? '', body?.projectDir).catch(fail);
|
|
131
135
|
}
|
|
@@ -500,6 +504,10 @@ let OperatorController = class OperatorController {
|
|
|
500
504
|
vcsStatus(id) {
|
|
501
505
|
return this.ops.vcsStatus(id).catch(fail);
|
|
502
506
|
}
|
|
507
|
+
/** The operator's approval of the starting snapshot: one choice per file of the list they saw. */
|
|
508
|
+
vcsSnapshot(id, body) {
|
|
509
|
+
return this.ops.vcsSnapshot(id, body?.choices ?? {}).catch(fail);
|
|
510
|
+
}
|
|
503
511
|
// --- the model picker ---------------------------------------------------------------------
|
|
504
512
|
/** The cached list. Returns null when the picker has never been read on this machine. */
|
|
505
513
|
models() {
|
|
@@ -632,6 +640,13 @@ __decorate([
|
|
|
632
640
|
__metadata("design:paramtypes", []),
|
|
633
641
|
__metadata("design:returntype", Promise)
|
|
634
642
|
], OperatorController.prototype, "sessions", null);
|
|
643
|
+
__decorate([
|
|
644
|
+
Put('sessions-order'),
|
|
645
|
+
__param(0, Body()),
|
|
646
|
+
__metadata("design:type", Function),
|
|
647
|
+
__metadata("design:paramtypes", [Object]),
|
|
648
|
+
__metadata("design:returntype", Promise)
|
|
649
|
+
], OperatorController.prototype, "reorderSessions", null);
|
|
635
650
|
__decorate([
|
|
636
651
|
Post('sessions'),
|
|
637
652
|
__param(0, Body()),
|
|
@@ -1027,6 +1042,14 @@ __decorate([
|
|
|
1027
1042
|
__metadata("design:paramtypes", [String]),
|
|
1028
1043
|
__metadata("design:returntype", Promise)
|
|
1029
1044
|
], OperatorController.prototype, "vcsStatus", null);
|
|
1045
|
+
__decorate([
|
|
1046
|
+
Post('sessions/:id/vcs/snapshot'),
|
|
1047
|
+
__param(0, Param('id')),
|
|
1048
|
+
__param(1, Body()),
|
|
1049
|
+
__metadata("design:type", Function),
|
|
1050
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
1051
|
+
__metadata("design:returntype", Promise)
|
|
1052
|
+
], OperatorController.prototype, "vcsSnapshot", null);
|
|
1030
1053
|
__decorate([
|
|
1031
1054
|
Get('models'),
|
|
1032
1055
|
__metadata("design:type", Function),
|
|
@@ -45,6 +45,7 @@ import { unattendedPrecondition } from '../exec/policy.js';
|
|
|
45
45
|
import { createTransport } from '../transport/chatTransport.js';
|
|
46
46
|
import { composeHandoff } from '../session/handoff.js';
|
|
47
47
|
import { planSync } from '../vcs/syncCommand.js';
|
|
48
|
+
import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
|
|
48
49
|
import { computeMetrics } from '../session/metrics.js';
|
|
49
50
|
import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
|
|
50
51
|
import { saveAndReveal } from './saveToDesktop.js';
|
|
@@ -320,6 +321,14 @@ let OperatorService = class OperatorService {
|
|
|
320
321
|
await this.store.deletePersona(name);
|
|
321
322
|
}
|
|
322
323
|
// --- sessions and tasks ---------------------------------------------------------------
|
|
324
|
+
/** The operator's order for the sessions list, top to bottom. See `SessionStore.reorderSessions`. */
|
|
325
|
+
async reorderSessions(ids) {
|
|
326
|
+
await this.init();
|
|
327
|
+
if (!Array.isArray(ids) || ids.some((x) => typeof x !== 'string'))
|
|
328
|
+
throw new Error('ids must be a list of session ids.');
|
|
329
|
+
await this.store.reorderSessions(ids);
|
|
330
|
+
return await this.listSessions();
|
|
331
|
+
}
|
|
323
332
|
async listSessions() {
|
|
324
333
|
await this.init();
|
|
325
334
|
const all = await this.store.listSessions();
|
|
@@ -404,6 +413,12 @@ let OperatorService = class OperatorService {
|
|
|
404
413
|
merged.baseBranch = merged.baseBranch.trim();
|
|
405
414
|
if (merged.existingBranch !== undefined)
|
|
406
415
|
merged.existingBranch = merged.existingBranch.trim();
|
|
416
|
+
// Only the two known fields, and absent when it says what absent says: reject, asking first.
|
|
417
|
+
const dirty = dirtyPolicy(merged);
|
|
418
|
+
if (dirty.policy === 'reject')
|
|
419
|
+
delete merged.dirtyWorktree;
|
|
420
|
+
else
|
|
421
|
+
merged.dirtyWorktree = { policy: dirty.policy, ...(dirty.requireApproval ? {} : { requireApproval: false }) };
|
|
407
422
|
// Only the program marks a name as one of its own branches, and a name changed is not that one.
|
|
408
423
|
const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
|
|
409
424
|
if (stillOwn)
|
|
@@ -1822,11 +1837,27 @@ let OperatorService = class OperatorService {
|
|
|
1822
1837
|
return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
|
|
1823
1838
|
// `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
|
|
1824
1839
|
// those differ, and the second is the one the operator is asking about.
|
|
1825
|
-
const preflight = await vcsPreflight(session);
|
|
1840
|
+
const preflight = await vcsPreflight(session, () => this.store.listSessions());
|
|
1826
1841
|
// The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
|
|
1827
1842
|
const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
|
|
1828
1843
|
return { ...preflight, git, work: sessionBranches(session), branches };
|
|
1829
1844
|
}
|
|
1845
|
+
/**
|
|
1846
|
+
* The operator's approval of the starting snapshot: their uncommitted changes become the commit the
|
|
1847
|
+
* session starts from, each file taken or left out as they chose on the list. See `vcs/snapshot.ts`.
|
|
1848
|
+
* Refused while anything could be working in the repository, as a restore is: it changes branches.
|
|
1849
|
+
*/
|
|
1850
|
+
async vcsSnapshot(sessionId, choices) {
|
|
1851
|
+
await this.init();
|
|
1852
|
+
const session = await this.store.getSession(sessionId);
|
|
1853
|
+
if (!session)
|
|
1854
|
+
throw new Error('No such session.');
|
|
1855
|
+
const refused = await this.restoreRefusal(session);
|
|
1856
|
+
if (refused)
|
|
1857
|
+
return { ok: false, problem: refused };
|
|
1858
|
+
const taken = await takeSnapshot(session, { approved: true, choices: choices && typeof choices === 'object' ? choices : {} }, this.bus, async (mutate) => void (await this.store.updateSession(sessionId, mutate)), () => this.store.listSessions());
|
|
1859
|
+
return taken.ok ? { ok: true, branch: taken.start.branch, commit: taken.start.commit } : { ok: false, problem: taken.problem };
|
|
1860
|
+
}
|
|
1830
1861
|
/**
|
|
1831
1862
|
* What going back to before a task would do, and then doing it.
|
|
1832
1863
|
*
|
package/dist/src/plan/brief.js
CHANGED
|
@@ -454,6 +454,16 @@ Before a session's first branch the runner fetches and fast-forwards the branch
|
|
|
454
454
|
merges. \`"updateFromRemote": false\` turns that off — only when the user asks to work offline or from
|
|
455
455
|
local commits on purpose.
|
|
456
456
|
|
|
457
|
+
**dirtyWorktree** — what happens when the repository has uncommitted changes (the user's own, such as
|
|
458
|
+
input files a task needs) before a session's first task. Ask only when the user mentions such files:
|
|
459
|
+
\`{ "policy": "snapshot" }\` makes them one commit, "Capture operator baseline before run", on a branch of
|
|
460
|
+
its own, and the session starts from it; the user approves the exact list on the session's page first.
|
|
461
|
+
\`"tracked-only-snapshot"\` takes tracked changes only. Secrets (\`.env\`), tool output and files outside
|
|
462
|
+
the project are never taken, and an ignored file only when a task's \`scope\` names it. The snapshot is
|
|
463
|
+
taken where the changes are, so use it with \`startFrom\` \`"branch"\` only when the repository is already on
|
|
464
|
+
that branch, and never with \`"existing-branch"\`. Leaving it out keeps \`"reject"\`: version control stays out
|
|
465
|
+
of a tree with uncommitted changes.
|
|
466
|
+
|
|
457
467
|
Put the same \`startFrom\` on every session that works in one repository unless the user wants it
|
|
458
468
|
mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
|
|
459
469
|
the one to avoid.
|
|
@@ -512,6 +522,16 @@ const VCS_DETAIL_BG = `
|
|
|
512
522
|
reset или merge. \`"updateFromRemote": false\` го изключва — само ако потребителят поиска да работи
|
|
513
523
|
офлайн или нарочно от локални commit-и.
|
|
514
524
|
|
|
525
|
+
**dirtyWorktree** — какво става, когато хранилището има некомитнати промени (на потребителя, например
|
|
526
|
+
входни файлове, които задача използва) преди първата задача на сесията. Питай само ако потребителят
|
|
527
|
+
спомене такива файлове: \`{ "policy": "snapshot" }\` ги прави един commit, „Capture operator baseline before
|
|
528
|
+
run“, на отделен клон, и сесията тръгва от него; потребителят първо одобрява точния списък на страницата на
|
|
529
|
+
сесията. \`"tracked-only-snapshot"\` взема само tracked промени. Тайни (\`.env\`), изход от инструменти и
|
|
530
|
+
файлове извън проекта никога не се вземат, а ignored файл — само когато \`scope\` на задача го посочва.
|
|
531
|
+
Снимката се прави там, където са промените, затова с \`startFrom\` \`"branch"\` само ако хранилището вече е на
|
|
532
|
+
този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: контролът на версиите
|
|
533
|
+
не влиза в дърво с некомитнати промени.
|
|
534
|
+
|
|
515
535
|
Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
|
|
516
536
|
различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
|
|
517
537
|
което е за избягване.
|
|
@@ -1438,6 +1458,13 @@ bot actually does, because it changes what a good task looks like:
|
|
|
1438
1458
|
reads it and decides the next step. This repeats until Copilot writes a final summary.
|
|
1439
1459
|
- Nothing is interactive. A command that waits for a keypress, opens an editor or needs a
|
|
1440
1460
|
browser login will hang the task.
|
|
1461
|
+
- **How the working chat learns the project: by running commands.** No file of the project is
|
|
1462
|
+
copied, attached or uploaded to it. In each task it reads what it needs itself, with terminal
|
|
1463
|
+
commands in the project folder — it lists the folders, opens the files, looks at the git log —
|
|
1464
|
+
and the output comes back to it. So never ask the user how the chat will get the project's files,
|
|
1465
|
+
how it will see the code or whether to attach anything; that is settled. If the organisation
|
|
1466
|
+
document still says how the chat reaches a project (attached files, a Desktop copy), ignore it:
|
|
1467
|
+
that way no longer exists. Write prompts that name the paths to read, not files to attach.
|
|
1441
1468
|
- **Only the project folders.** The runner refuses any command, check or file path that reaches
|
|
1442
1469
|
outside the session's folder and the projects listed below — reading as well as writing — and
|
|
1443
1470
|
anything that manages the machine: the registry, services, the network, users, installs that
|
|
@@ -1655,6 +1682,13 @@ function buildBg(projects, ctx = {}) {
|
|
|
1655
1682
|
чете и решава следващата стъпка. Това се повтаря, докато Copilot не напише финално резюме.
|
|
1656
1683
|
- Нищо не е интерактивно. Команда, която чака клавиш, отваря редактор или иска вход през
|
|
1657
1684
|
браузър, ще увисне.
|
|
1685
|
+
- **Как работният чат опознава проекта: с команди.** Никой файл от проекта не се копира, прикачва
|
|
1686
|
+
или качва в него. Във всяка задача той сам чете каквото му трябва, с терминални команди в папката
|
|
1687
|
+
на проекта — изброява папките, отваря файловете, гледа git историята — и изходът се връща при него.
|
|
1688
|
+
Затова никога не питай потребителя как чатът ще получи файловете на проекта, как ще види кода или
|
|
1689
|
+
дали да прикачи нещо; това е решено. Ако документът за организацията още казва как чатът стига до
|
|
1690
|
+
проект (прикачени файлове, копие на Desktop-а), не го слушай: такъв начин вече няма. Пиши prompt-ове,
|
|
1691
|
+
които назовават пътищата за четене, а не файлове за прикачване.
|
|
1658
1692
|
- **Само папките на проектите.** Runner-ът отказва всяка команда, проверка или път към файл, който
|
|
1659
1693
|
излиза извън папката на сесията и проектите, изброени по-долу — и за четене, и за запис — и всичко,
|
|
1660
1694
|
което управлява машината: регистъра, услугите, мрежата, потребителите, инсталации извън проекта.
|
|
@@ -172,7 +172,12 @@ defaultProjectDir = '') {
|
|
|
172
172
|
'the chat reads the project by running commands. "mirror" was ignored.');
|
|
173
173
|
}
|
|
174
174
|
const created = await store.createSession(planned.name, planned.projectDir || planned.mirror?.rootDir || '');
|
|
175
|
-
const
|
|
175
|
+
const { dirtyWorktree: plannedDirty, ...plannedVcs } = planned.vcs ?? {};
|
|
176
|
+
const vcs = { ...DEFAULT_VCS, ...plannedVcs };
|
|
177
|
+
// Stored in the one spelling the session has: see `DirtyWorktree`.
|
|
178
|
+
const policy = plannedDirty?.policy === 'snapshot' && plannedDirty.includeUntracked === false ? 'tracked-only-snapshot' : plannedDirty?.policy;
|
|
179
|
+
if (policy && policy !== 'reject')
|
|
180
|
+
vcs.dirtyWorktree = { policy, ...(plannedDirty?.requireApproval === false ? { requireApproval: false } : {}) };
|
|
176
181
|
vcs.branchPrefix = vcs.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
|
|
177
182
|
vcs.repoDir = vcs.repoDir.trim();
|
|
178
183
|
// A branch to carry on, given alone, is that choice: see `VersionControl.startFrom`.
|
|
@@ -211,5 +216,7 @@ defaultProjectDir = '') {
|
|
|
211
216
|
}
|
|
212
217
|
sessions.push({ id: created.id, name: created.name, tasks: titles.length, titles });
|
|
213
218
|
}
|
|
219
|
+
// The plan's sessions on top of the list, in the order the plan gives them, not newest first.
|
|
220
|
+
await store.placeOnTop(sessions.map((s) => s.id));
|
|
214
221
|
return { sessions, taskCount: sessions.reduce((n, s) => n + s.tasks, 0), warnings };
|
|
215
222
|
}
|
package/dist/src/plan/schema.js
CHANGED
|
@@ -60,6 +60,23 @@ const VcsInput = z
|
|
|
60
60
|
* Absent means true. See `VersionControl.updateFromRemote`.
|
|
61
61
|
*/
|
|
62
62
|
updateFromRemote: z.boolean().optional(),
|
|
63
|
+
/**
|
|
64
|
+
* What the session's first run does with uncommitted changes. See `DirtyWorktree`. Accepts the
|
|
65
|
+
* longer spelling a chat model may write — `includeUntracked: false` is `tracked-only-snapshot`,
|
|
66
|
+
* and `includeIgnoredOnlyWhenScoped` can only be true, since that is the rule, not a choice.
|
|
67
|
+
*/
|
|
68
|
+
dirtyWorktree: z
|
|
69
|
+
.object({
|
|
70
|
+
policy: z.enum(['reject', 'snapshot', 'tracked-only-snapshot'], {
|
|
71
|
+
error: 'dirtyWorktree.policy must be "reject" (uncommitted changes keep version control out, as until now), "snapshot" (they become the commit the session starts from) or "tracked-only-snapshot" (the same, tracked changes only). "discard" is not offered: the runner never throws work away.',
|
|
72
|
+
}),
|
|
73
|
+
requireApproval: z.boolean().optional(),
|
|
74
|
+
includeUntracked: z.boolean().optional(),
|
|
75
|
+
includeIgnoredOnlyWhenScoped: z
|
|
76
|
+
.literal(true, { error: 'dirtyWorktree.includeIgnoredOnlyWhenScoped can only be true: an ignored file goes into a snapshot only when a task\'s scope names it. Leave the field out.' })
|
|
77
|
+
.optional(),
|
|
78
|
+
})
|
|
79
|
+
.optional(),
|
|
63
80
|
}, {
|
|
64
81
|
error: 'Every session needs a "vcs" object saying whether the runner does version control for it and, when it does, which git repository to work in. Ask the user both questions and write the answers here.',
|
|
65
82
|
})
|
|
@@ -78,6 +95,13 @@ const VcsInput = z
|
|
|
78
95
|
message: `"existingBranch" carries on a branch that exists, and startFrom "${vcs.startFrom}" cuts a new one. Keep one: startFrom "existing-branch" with existingBranch, or leave existingBranch out.`,
|
|
79
96
|
});
|
|
80
97
|
}
|
|
98
|
+
if (vcs.dirtyWorktree?.policy === 'tracked-only-snapshot' && vcs.dirtyWorktree.includeUntracked === true) {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: 'custom',
|
|
101
|
+
path: ['dirtyWorktree', 'includeUntracked'],
|
|
102
|
+
message: '"tracked-only-snapshot" takes tracked changes only, and includeUntracked true asks for new files too. Keep one: policy "snapshot", or leave includeUntracked out.',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
81
105
|
if (vcs.enabled && !vcs.repoDir.trim()) {
|
|
82
106
|
ctx.addIssue({
|
|
83
107
|
code: 'custom',
|
|
@@ -827,6 +827,22 @@ export const SYSTEM_GUIDE = [
|
|
|
827
827
|
doesEn: 'Deletes every session ticked in the first column after one question, also during a run; never the one running. Run folders and chats survive.',
|
|
828
828
|
doesBg: 'Изтрива всички отметнати в първата колона сесии след един въпрос, и по време на пускане; никога работещата. Папките и разговорите остават.',
|
|
829
829
|
},
|
|
830
|
+
{
|
|
831
|
+
key: 'home.moveUp',
|
|
832
|
+
kind: 'button',
|
|
833
|
+
en: 'Move {name} up in the list',
|
|
834
|
+
bg: 'Премести {name} нагоре в списъка',
|
|
835
|
+
doesEn: 'The ↑ arrow in the Order column: one place up. ↓ is one place down. Dragging a row onto another moves it there. An imported plan comes in on top in its own order.',
|
|
836
|
+
doesBg: 'Стрелката ↑ в колона „Ред“: едно място нагоре. ↓ е едно надолу. Плъзгане на ред върху друг го мести там. Импортиран план идва най-отгоре в своя ред.',
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
key: 'home.moveDown',
|
|
840
|
+
kind: 'button',
|
|
841
|
+
en: 'Move {name} down in the list',
|
|
842
|
+
bg: 'Премести {name} надолу в списъка',
|
|
843
|
+
doesEn: 'The ↓ arrow in the Order column: one place down. A run of several takes the ticked sessions top to bottom.',
|
|
844
|
+
doesBg: 'Стрелката ↓ в колона „Ред“: едно място надолу. Пускане на няколко взима отметнатите сесии отгоре надолу.',
|
|
845
|
+
},
|
|
830
846
|
{
|
|
831
847
|
key: 'home.delete',
|
|
832
848
|
kind: 'button',
|
|
@@ -1192,6 +1208,22 @@ export const SYSTEM_GUIDE = [
|
|
|
1192
1208
|
doesEn: 'One local commit per task whatever the outcome; unticked leaves the changes loose in the working tree.',
|
|
1193
1209
|
doesBg: 'По един локален комит на задача, независимо от изхода; без отметка промените остават свободни в работното дърво.',
|
|
1194
1210
|
},
|
|
1211
|
+
{
|
|
1212
|
+
key: 'vcs.dirty',
|
|
1213
|
+
kind: 'note',
|
|
1214
|
+
en: 'Uncommitted changes',
|
|
1215
|
+
bg: 'Некомитнати промени',
|
|
1216
|
+
doesEn: 'What the first task does when the repository has uncommitted changes: keep version control out (as until now), or take them — all, or tracked only — as a starting snapshot: one commit on a branch of its own that the session starts from. In a plan: "dirtyWorktree": { "policy": "snapshot" }. Not shown for "Carry on an existing branch".',
|
|
1217
|
+
doesBg: 'Какво прави първата задача, когато хранилището има некомитнати промени: без контрол на версиите (както досега) или ги взема — всички или само tracked — като начална снимка: един комит на отделен клон, от който сесията тръгва. В план: "dirtyWorktree": { "policy": "snapshot" }. Не се показва при „Продължи съществуващ клон“.',
|
|
1218
|
+
},
|
|
1219
|
+
{
|
|
1220
|
+
key: 'vcs.snapshotTake',
|
|
1221
|
+
kind: 'button',
|
|
1222
|
+
en: 'Take the starting snapshot',
|
|
1223
|
+
bg: 'Направи началната снимка',
|
|
1224
|
+
doesEn: 'Under the list of uncommitted files, each set to "Include in the starting snapshot" or "Leave out of the run": commits the included ones on the snapshot branch; left-out files go to the repository\'s .git/info/exclude. Until it is pressed a run of the session is refused and nothing changes.',
|
|
1225
|
+
doesBg: 'Под списъка с некомитнати файлове, всеки с „Включи в началната снимка“ или „Остави извън изпълнението“: комитва включените на клона на снимката; оставените извън отиват в .git/info/exclude на хранилището. Докато не е натиснат, пускане на сесията се отказва и нищо не се променя.',
|
|
1226
|
+
},
|
|
1195
1227
|
{
|
|
1196
1228
|
key: 'vcs.ready',
|
|
1197
1229
|
kind: 'note',
|
|
@@ -147,6 +147,8 @@ export function buildPlanExport(scope) {
|
|
|
147
147
|
...(s.vcs?.existingBranch ? { existingBranch: s.vcs.existingBranch } : {}),
|
|
148
148
|
// Only when switched off: absent is on.
|
|
149
149
|
...(s.vcs?.updateFromRemote === false ? { updateFromRemote: false } : {}),
|
|
150
|
+
// Only when chosen: absent is reject, as it always was.
|
|
151
|
+
...(s.vcs?.dirtyWorktree && s.vcs.dirtyWorktree.policy !== 'reject' ? { dirtyWorktree: { ...s.vcs.dirtyWorktree } } : {}),
|
|
150
152
|
},
|
|
151
153
|
review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
|
|
152
154
|
...(s.projectDir ? { projectDir: s.projectDir } : {}),
|
|
@@ -29,6 +29,27 @@ const ACTIVE_STATUSES = ['running', 'waiting-approval'];
|
|
|
29
29
|
* feature works at all would mean most runs go unreviewed.
|
|
30
30
|
*/
|
|
31
31
|
export const DEFAULT_REVIEW = { enabled: true, model: '' };
|
|
32
|
+
/**
|
|
33
|
+
* Where a session sits in the list: its position, or, for one saved before the list could be
|
|
34
|
+
* arranged, minus the time it was created — which keeps those newest first, as they always were,
|
|
35
|
+
* and above any arranged session only if they are newer than the list's top was.
|
|
36
|
+
*/
|
|
37
|
+
export function sessionListKey(s) {
|
|
38
|
+
if (typeof s.position === 'number' && Number.isFinite(s.position))
|
|
39
|
+
return s.position;
|
|
40
|
+
const t = Date.parse(s.createdAt);
|
|
41
|
+
return Number.isNaN(t) ? 0 : -t;
|
|
42
|
+
}
|
|
43
|
+
/** The sessions list's order: by position, then newest first, then by id so it never wobbles. */
|
|
44
|
+
export function bySessionList(a, b) {
|
|
45
|
+
const byKey = sessionListKey(a) - sessionListKey(b);
|
|
46
|
+
if (byKey !== 0)
|
|
47
|
+
return byKey;
|
|
48
|
+
const byTime = Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
|
49
|
+
if (byTime !== 0 && !Number.isNaN(byTime))
|
|
50
|
+
return byTime;
|
|
51
|
+
return b.id.localeCompare(a.id);
|
|
52
|
+
}
|
|
32
53
|
export const DEFAULT_VCS = {
|
|
33
54
|
enabled: true,
|
|
34
55
|
repoDir: '',
|
|
@@ -254,12 +275,50 @@ export class SessionStore {
|
|
|
254
275
|
if (s)
|
|
255
276
|
out.push(s);
|
|
256
277
|
}
|
|
257
|
-
return out.sort(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
return out.sort(bySessionList);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Puts these sessions at the top of the list, in this order.
|
|
282
|
+
*
|
|
283
|
+
* An import calls it with the plan's sessions, so they appear as the plan wrote them. Before
|
|
284
|
+
* this, the list was newest first, and a plan whose sessions were created one after another
|
|
285
|
+
* read upside down.
|
|
286
|
+
*/
|
|
287
|
+
async placeOnTop(ids) {
|
|
288
|
+
const wanted = new Set(ids);
|
|
289
|
+
const others = (await this.listSessions()).filter((s) => !wanted.has(s.id));
|
|
290
|
+
const top = others.length > 0 ? Math.min(...others.map(sessionListKey)) : 0;
|
|
291
|
+
for (const [i, id] of ids.entries()) {
|
|
292
|
+
await this.updateSession(id, (s) => {
|
|
293
|
+
s.position = top - ids.length + i;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The operator's own order: every session, top to bottom.
|
|
299
|
+
*
|
|
300
|
+
* The whole list is given, not one move, so two pages moving rows at once cannot leave positions
|
|
301
|
+
* that contradict each other: the last order sent is the order there is. A list that names a
|
|
302
|
+
* session that does not exist, or leaves one out, is refused rather than guessed at.
|
|
303
|
+
*/
|
|
304
|
+
async reorderSessions(ids) {
|
|
305
|
+
const all = await this.listSessions();
|
|
306
|
+
const known = new Set(all.map((s) => s.id));
|
|
307
|
+
const given = new Set(ids);
|
|
308
|
+
if (given.size !== ids.length)
|
|
309
|
+
throw new Error('The order names a session twice.');
|
|
310
|
+
const unknown = ids.filter((id) => !known.has(id));
|
|
311
|
+
if (unknown.length > 0)
|
|
312
|
+
throw new Error(`The order names a session that does not exist: ${unknown.join(', ')}.`);
|
|
313
|
+
const missing = all.filter((s) => !given.has(s.id)).map((s) => s.name);
|
|
314
|
+
if (missing.length > 0)
|
|
315
|
+
throw new Error(`The order leaves out ${missing.length} session(s): ${missing.slice(0, 5).join(', ')}. Reload the list and try again.`);
|
|
316
|
+
for (const [i, id] of ids.entries()) {
|
|
317
|
+
await this.updateSession(id, (s) => {
|
|
318
|
+
s.position = i;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return await this.listSessions();
|
|
263
322
|
}
|
|
264
323
|
/**
|
|
265
324
|
* The session of this id, or null when there is none.
|
|
@@ -282,10 +341,13 @@ export class SessionStore {
|
|
|
282
341
|
return await this.readSession(path);
|
|
283
342
|
}
|
|
284
343
|
async createSession(name, projectDir = '') {
|
|
344
|
+
// A new session goes on top of the list, where it is looked for.
|
|
345
|
+
const existing = await this.listSessions();
|
|
285
346
|
const session = {
|
|
286
347
|
id: newId(),
|
|
287
348
|
name: name.trim() || 'untitled',
|
|
288
349
|
createdAt: new Date().toISOString(),
|
|
350
|
+
position: existing.length > 0 ? Math.min(...existing.map(sessionListKey)) - 1 : 0,
|
|
289
351
|
status: 'idle',
|
|
290
352
|
contractSent: false,
|
|
291
353
|
// A queue is a chain until the operator says otherwise, which is how it has always
|